diff --git a/packages/common/__tests__/font.test.ts b/packages/common/__tests__/font.test.ts index 97c7641b..30e0367a 100644 --- a/packages/common/__tests__/font.test.ts +++ b/packages/common/__tests__/font.test.ts @@ -4,7 +4,9 @@ import type { Font as FontKitFont } from 'fontkit'; import { calculateDynamicFontSize, checkFont, + getBrowserVerticalFontAdjustments, getDefaultFont, + getFontDescentInPt, getFontKitFont, getSplittedLines, } from '../src/font' @@ -460,3 +462,55 @@ describe('calculateDynamicFontSize with Custom font', () => { expect(result).toBe(5); }); }); + +describe('getFontDescentInPt test', () => { + test('it gets a descent size relative to the font size', () => { + expect(getFontDescentInPt({ descent: -400, unitsPerEm: 1000 } as FontKitFont, 12)).toBe( + -4.800000000000001 + ); + expect(getFontDescentInPt({ descent: 54, unitsPerEm: 1000 } as FontKitFont, 20)).toBe(1.08); + expect(getFontDescentInPt({ descent: -512, unitsPerEm: 2048 } as FontKitFont, 54)).toBe(-13.5); + }); +}); + +describe('getBrowserVerticalFontAdjustments test', () => { + // Font with a base line-height of 1.349 + const font = { ascent: 1037, descent: -312, unitsPerEm: 1000 } as FontKitFont; + + test('it gets a top adjustment when vertically aligning top', () => { + expect(getBrowserVerticalFontAdjustments(font, 12, 1.0, 'top')).toEqual({ + topAdj: 2.791301999999999, + bottomAdj: 0, + }); + expect(getBrowserVerticalFontAdjustments(font, 36, 2.0, 'top')).toEqual({ + topAdj: 8.373906, + bottomAdj: 0, + }); + }); + + test('it gets a bottom adjustment when vertically aligning middle or bottom', () => { + expect(getBrowserVerticalFontAdjustments(font, 12, 1.0, 'bottom')).toEqual({ + topAdj: 0, + bottomAdj: 2.791302, + }); + expect(getBrowserVerticalFontAdjustments(font, 12, 1.15, 'middle')).toEqual({ + topAdj: 0, + bottomAdj: 1.5916020000000004, + }); + }); + + test('it does not get a bottom adjustment if the line height exceeds that of the font', () => { + expect(getBrowserVerticalFontAdjustments(font, 12, 1.35, 'bottom')).toEqual({ + topAdj: 0, + bottomAdj: 0, + }); + }); + + test('it does not get a bottom adjustment if the font base line-height is 1.0 or less', () => { + const thisFont = { ascent: 900, descent: -50, unitsPerEm: 1000 } as FontKitFont; + expect(getBrowserVerticalFontAdjustments(thisFont, 20, 1.0, 'bottom')).toEqual({ + topAdj: 0, + bottomAdj: 0, + }); + }); +}); diff --git a/packages/common/__tests__/helper.test.ts b/packages/common/__tests__/helper.test.ts index d6a2a2e1..950bd175 100644 --- a/packages/common/__tests__/helper.test.ts +++ b/packages/common/__tests__/helper.test.ts @@ -1,4 +1,5 @@ -import { validateBarcodeInput, mm2pt, pt2mm } from '../src/helper'; +import { validateBarcodeInput, mm2pt, pt2mm, pt2px } from '../src/helper'; +import { PT_TO_PX_RATIO } from "../src"; describe('validateBarcodeInput test', () => { test('qrcode', () => { @@ -264,3 +265,13 @@ describe('pt2mm test', () => { expect(pt2mm(5322.98)).toEqual(1877.947344); }); }); + +describe('pt2px test', () => { + it('converts points to pixels', () => { + expect(pt2px(1)).toEqual(PT_TO_PX_RATIO); + expect(pt2px(1)).toEqual(1.333); + expect(pt2px(2.8346)).toEqual(3.7785218); + expect(pt2px(10)).toEqual(13.33); + expect(pt2px(5322.98)).toEqual(7095.532339999999); + }); +}); \ No newline at end of file diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 63507717..08b35ccd 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -1,6 +1,10 @@ export const DEFAULT_FONT_NAME = 'Roboto'; export const DEFAULT_FONT_SIZE = 13; export const DEFAULT_ALIGNMENT = 'left'; +export const VERTICAL_ALIGN_TOP = 'top'; +export const VERTICAL_ALIGN_MIDDLE = 'middle'; +export const VERTICAL_ALIGN_BOTTOM = 'bottom'; +export const DEFAULT_VERTICAL_ALIGNMENT = VERTICAL_ALIGN_TOP; export const DEFAULT_LINE_HEIGHT = 1; export const DEFAULT_CHARACTER_SPACING = 0; export const DEFAULT_FONT_COLOR = '#000'; diff --git a/packages/common/src/font.ts b/packages/common/src/font.ts index 7e7ef9c2..9f11586e 100644 --- a/packages/common/src/font.ts +++ b/packages/common/src/font.ts @@ -9,12 +9,12 @@ import { DEFAULT_CHARACTER_SPACING, DEFAULT_LINE_HEIGHT, FONT_SIZE_ADJUSTMENT, - PT_TO_PX_RATIO, DEFAULT_DYNAMIC_FIT, DYNAMIC_FIT_HORIZONTAL, DYNAMIC_FIT_VERTICAL, + VERTICAL_ALIGN_TOP, } from './constants'; -import { mm2pt, pt2mm } from './helper'; +import { mm2pt, pt2mm, pt2px } from './helper'; import { b64toUint8Array } from "." export const getFallbackFontName = (font: Font) => { @@ -77,20 +77,50 @@ export const checkFont = (arg: { font: Font; template: Template }) => { } }; -export const getFontAlignmentValue = (fontKitFont: FontKitFont, fontSize: number) => { +export const getBrowserVerticalFontAdjustments = ( + fontKitFont: FontKitFont, + fontSize: number, + lineHeight: number, + verticalAlignment: string +) => { const { ascent, descent, unitsPerEm } = fontKitFont; - const fontSizeInPx = fontSize * PT_TO_PX_RATIO; + // Fonts have a designed line height that the browser renders when using `line-height: normal` + const fontBaseLineHeight = (ascent - descent) / unitsPerEm; - // Convert ascent and descent to px values - const ascentInPixels = (ascent / unitsPerEm) * fontSizeInPx; - const descentInPixels = (descent / unitsPerEm) * fontSizeInPx; + // For vertical alignment top + // To achieve consistent positioning between browser and PDF, we apply the difference between + // the font's actual height and the font size in pixels. + // Browsers middle the font within this height, so we only need half of it to apply to the top. + // This means the font renders a bit lower in the browser, but achieves PDF alignment + const topAdjustment = (fontBaseLineHeight * fontSize - fontSize) / 2; - // Calculate the single line height in px - const singleLineHeight = ((ascentInPixels + Math.abs(descentInPixels)) / fontSizeInPx); + if (verticalAlignment === VERTICAL_ALIGN_TOP) { + return { topAdj: pt2px(topAdjustment), bottomAdj: 0 }; + } - // Calculate the top margin/padding in px - return ((singleLineHeight * fontSizeInPx) - fontSizeInPx) / 2 + // For vertical alignment bottom and middle + // When browsers render text in a non-form element (such as a
), some of the text may be + // lowered below and outside the containing element if the line height used is less than + // the base line-height of the font. + // This behaviour does not happen in a + +
@@ -227,10 +231,10 @@ exports[`Preview(as Viewer) snapshot 1`] = ` title="field1" >
{ const { changeSchemas, activeSchema } = props; const alignments = ['left', 'center', 'right']; + const verticalAlignments = [VERTICAL_ALIGN_TOP, VERTICAL_ALIGN_MIDDLE, VERTICAL_ALIGN_BOTTOM]; const dynamicFits = [DYNAMIC_FIT_HORIZONTAL, DYNAMIC_FIT_VERTICAL]; const font = useContext(FontContext); const fallbackFontName = getFallbackFontName(font); @@ -180,13 +185,22 @@ const TextPropEditor = ( /> changeSchemas([{ key: 'alignment', value: e.target.value, schemaId: activeSchema.id }]) } /> + + { + changeSchemas([{ key: 'verticalAlignment', value: e.target.value, schemaId: activeSchema.id }]); + }} + />
{ + switch (verticalAlignmentValue) { + case VERTICAL_ALIGN_TOP: + return 'flex-start'; + case VERTICAL_ALIGN_MIDDLE: + return 'center'; + case VERTICAL_ALIGN_BOTTOM: + return 'flex-end'; + } + return 'flex-start'; +}; + type Props = SchemaUIProps & { schema: TextSchema }; const TextSchemaUI = ( @@ -22,8 +38,8 @@ const TextSchemaUI = ( ) => { const font = useContext(FontContext); const [dynamicFontSize, setDynamicFontSize] = useState(undefined); - const [fontAlignmentValue, setFontAlignmentValue] = useState(0); - + const [topAdjustment, setTopAdjustment] = useState(0); + const [bottomAdjustment, setBottomAdjustment] = useState(0); useEffect(() => { if (schema.dynamicFontSize && schema.data) { @@ -50,10 +66,31 @@ const TextSchemaUI = ( useEffect(() => { getFontKitFont(schema, font).then(fontKitFont => { - const fav = getFontAlignmentValue(fontKitFont, dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE); - setFontAlignmentValue(fav); + // Depending on vertical alignment, we need to move the top or bottom of the font to keep + // it within it's defined box and align it with the generated pdf. + const { topAdj, bottomAdj } = getBrowserVerticalFontAdjustments( + fontKitFont, + dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE, + schema.lineHeight ?? DEFAULT_LINE_HEIGHT, + schema.verticalAlignment ?? DEFAULT_VERTICAL_ALIGNMENT + ); + setTopAdjustment(topAdj); + setBottomAdjustment(bottomAdj); }); + + if (ref && 'current' in ref) { + const textarea = ref.current; + + if (textarea) { + // Textareas cannot be vertically aligned, so we need to adjust the height of the textarea + // to exactly fit the height of it's content, whilst aligned within it's container. + // This gives the appearance of being vertically aligned. + textarea.style.height = 'auto'; // Reset the height to auto to ensure we get the correct height. + textarea.style.height = `${textarea.scrollHeight}px`; + } + } }, [ + schema.data, schema.width, schema.height, schema.fontName, @@ -63,20 +100,36 @@ const TextSchemaUI = ( schema.dynamicFontSize?.fit, schema.characterSpacing, schema.lineHeight, + schema.verticalAlignment, font, - dynamicFontSize + dynamicFontSize, + editable, ]); - - const style: React.CSSProperties = { + const containerStyle: React.CSSProperties = { position: 'absolute', top: 0, padding: 0, - height: fontAlignmentValue < 0 ? schema.height * ZOOM + Math.abs(fontAlignmentValue) : schema.height * ZOOM, + height: schema.height * ZOOM, width: schema.width * ZOOM, resize: 'none', - marginTop: fontAlignmentValue < 0 ? fontAlignmentValue : 0, - paddingTop: fontAlignmentValue >= 0 ? fontAlignmentValue : 0, + backgroundColor: schema.data && schema.backgroundColor ? schema.backgroundColor : 'rgb(242 244 255 / 75%)', + border: 'none', + display: 'flex', + flexDirection: 'column', + justifyContent: mapVerticalAlignToFlex(schema.verticalAlignment), + }; + + const textareaStyle: React.CSSProperties = { + padding: 0, + resize: 'none', + border: 'none', + outline: 'none', + paddingTop: topAdjustment, + background: 'none', + }; + + const fontStyles: React.CSSProperties = { fontFamily: schema.fontName ? `'${schema.fontName}'` : 'inherit', color: schema.fontColor ? schema.fontColor : DEFAULT_FONT_COLOR, fontSize: `${dynamicFontSize ?? schema.fontSize ?? DEFAULT_FONT_SIZE}pt`, @@ -85,27 +138,32 @@ const TextSchemaUI = ( textAlign: schema.alignment ?? DEFAULT_ALIGNMENT, whiteSpace: 'pre-wrap', wordBreak: 'break-word', - backgroundColor: - schema.data && schema.backgroundColor ? schema.backgroundColor : 'rgb(242 244 255 / 75%)', - border: 'none', }; return editable ? ( - +
+ +
) : ( -
-
+
+
{/* Set the letterSpacing of the last character to 0. */} {schema.data.split('').map((l, i) => ( - + {l} ))}