diff --git a/.changeset/soft-camels-visit.md b/.changeset/soft-camels-visit.md new file mode 100644 index 000000000..e36ed220f --- /dev/null +++ b/.changeset/soft-camels-visit.md @@ -0,0 +1,6 @@ +--- +"@react-pdf/textkit": minor +"@react-pdf/layout": minor +--- + +refactor: unify font substitution engines diff --git a/packages/layout/src/svg/layoutText.ts b/packages/layout/src/svg/layoutText.ts index a5790a6be..b96197429 100644 --- a/packages/layout/src/svg/layoutText.ts +++ b/packages/layout/src/svg/layoutText.ts @@ -6,6 +6,7 @@ import layoutEngine, { justification, scriptItemizer, wordHyphenation, + fontSubstitution, textDecoration, fromFragments, Fragment, @@ -13,7 +14,6 @@ import layoutEngine, { } from '@react-pdf/textkit'; import transformText from '../text/transformText'; -import fontSubstitution from '../text/fontSubstitution'; import { SafeNode, SafeTextNode, diff --git a/packages/layout/src/text/fontSubstitution.ts b/packages/layout/src/text/fontSubstitution.ts deleted file mode 100644 index ba4a082b4..000000000 --- a/packages/layout/src/text/fontSubstitution.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { last } from '@react-pdf/fns'; -import { AttributedString, Run } from '@react-pdf/textkit'; - -const IGNORED_CODE_POINTS = [173]; - -const getFontSize = (run: Run) => run.attributes.fontSize || 12; - -const pickFontFromFontStack = (codePoint, fontStack, lastFont) => { - const fontStackWithFallback = [...fontStack, lastFont]; - for (let i = 0; i < fontStackWithFallback.length; i += 1) { - const font = fontStackWithFallback[i]; - if ( - !IGNORED_CODE_POINTS.includes(codePoint) && - font && - font.hasGlyphForCodePoint && - font.hasGlyphForCodePoint(codePoint) - ) { - return font; - } - } - return fontStack.at(-1); -}; - -const fontSubstitution = - () => - ({ string, runs }: AttributedString) => { - let lastFont = null; - let lastFontSize = null; - let lastIndex = 0; - let index = 0; - - const res: Run[] = []; - - for (let i = 0; i < runs.length; i += 1) { - const run = runs[i]; - - if (string.length === 0) { - res.push({ - start: 0, - end: 0, - attributes: { font: run.attributes.font }, - }); - break; - } - - const chars = string.slice(run.start, run.end); - - for (let j = 0; j < chars.length; j += 1) { - const char = chars[j]; - const codePoint = char.codePointAt(0); - // If the default font does not have a glyph and the fallback font does, we use it - const font = pickFontFromFontStack( - codePoint, - run.attributes.font, - lastFont, - ); - - const fontSize = getFontSize(run); - - // If anything that would impact res has changed, update it - if ( - font !== lastFont || - fontSize !== lastFontSize || - font.unitsPerEm !== lastFont.unitsPerEm - ) { - if (lastFont) { - res.push({ - start: lastIndex, - end: index, - attributes: { - font: lastFont, - scale: lastFontSize / lastFont.unitsPerEm, - }, - }); - } - - lastFont = font; - lastFontSize = fontSize; - lastIndex = index; - } - - index += char.length; - } - } - - if (lastIndex < string.length) { - const fontSize = getFontSize(last(runs)); - - res.push({ - start: lastIndex, - end: string.length, - attributes: { - font: lastFont, - scale: fontSize / lastFont.unitsPerEm, - }, - }); - } - - return { string, runs: res } as AttributedString; - }; - -export default fontSubstitution; diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index 9cf17f456..e87235b67 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -5,10 +5,10 @@ import layoutEngine, { scriptItemizer, wordHyphenation, textDecoration, + fontSubstitution, } from '@react-pdf/textkit'; import FontStore from '@react-pdf/font'; -import fontSubstitution from './fontSubstitution'; import getAttributedString from './getAttributedString'; import { SafeTextNode } from '../types'; diff --git a/packages/layout/tests/text/fontSubstitution.test.ts b/packages/layout/tests/text/fontSubstitution.test.ts deleted file mode 100644 index 46c425b03..000000000 --- a/packages/layout/tests/text/fontSubstitution.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import fontSubstitution from '../../src/text/fontSubstitution'; -import FontStore from '@react-pdf/font'; - -const instance = fontSubstitution(); - -const fontStore = new FontStore(); - -describe('FontSubstitution', () => { - test('should return empty array if no runs passed', () => { - const string = instance({ string: '', runs: [] }); - - expect(string).toHaveProperty('runs', []); - expect(string).toHaveProperty('string', ''); - }); - - test('should merge consecutive runs with same font', () => { - const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; - - const run1 = { - start: 0, - end: 3, - attributes: { font: [helvetica] }, - } as any; - - const run2 = { - start: 3, - end: 5, - attributes: { font: [helvetica] }, - } as any; - - const string = instance({ string: 'Lorem', runs: [run1, run2] }); - - expect(string).toHaveProperty('string', 'Lorem'); - expect(string.runs).toHaveLength(1); - expect(string.runs[0]).toHaveProperty('start', 0); - expect(string.runs[0]).toHaveProperty('end', 5); - expect(string.runs[0].attributes.font).toBeTruthy(); - }); - - test('should substitute many runs', () => { - const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; - const helveticaBold = fontStore.getFont({ - fontFamily: 'Helvetica', - fontWeight: 700, - }).data; - - const run1 = { - start: 0, - end: 3, - attributes: { font: [helveticaBold] }, - } as any; - - const run2 = { start: 3, end: 5, attributes: { font: [helvetica] } } as any; - - const string = instance({ string: 'Lorem', runs: [run1, run2] }); - - expect(string).toHaveProperty('string', 'Lorem'); - expect(string.runs).toHaveLength(2); - expect(string.runs[0]).toHaveProperty('start', 0); - expect(string.runs[0]).toHaveProperty('end', 3); - expect(string.runs[0].attributes.font).toBeTruthy(); - expect(string.runs[1]).toHaveProperty('start', 3); - expect(string.runs[1]).toHaveProperty('end', 5); - expect(string.runs[1].attributes.font).toBeTruthy(); - }); - - describe('Fallback Font', () => { - const SimplifiedChineseFont = { - name: 'SimplifiedChineseFont', - hasGlyphForCodePoint: (codePoint) => codePoint === 20320, - }; - - test('should utilize a fallback font that supports the provided glyph', () => { - const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; - - const run = { - start: 0, - end: 1, - attributes: { - font: [helvetica, SimplifiedChineseFont], - }, - } as any; - - const string = instance({ string: '你', runs: [run] }); - - expect(string).toHaveProperty('string', '你'); - expect(string.runs).toHaveLength(1); - expect(string.runs[0]).toHaveProperty('start', 0); - expect(string.runs[0]).toHaveProperty('end', 1); - expect(string.runs[0].attributes.font).toBe(SimplifiedChineseFont); - }); - - test('should split a run when fallback font is used on a portion of the run', () => { - const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; - - const run = { - start: 0, - end: 2, - attributes: { - font: [helvetica, SimplifiedChineseFont], - }, - } as any; - - const string = instance({ string: 'A你', runs: [run] }); - - expect(string).toHaveProperty('string', 'A你'); - expect(string.runs).toHaveLength(2); - expect(string.runs[0]).toHaveProperty('start', 0); - expect(string.runs[0]).toHaveProperty('end', 1); - expect(string.runs[0].attributes.font).toBeTruthy(); - expect(string.runs[1]).toHaveProperty('start', 1); - expect(string.runs[1]).toHaveProperty('end', 2); - expect(string.runs[1].attributes.font).toBe(SimplifiedChineseFont); - }); - }); -}); diff --git a/packages/textkit/src/engines/fontSubstitution/index.ts b/packages/textkit/src/engines/fontSubstitution/index.ts index dfe05c0e8..b840053c0 100644 --- a/packages/textkit/src/engines/fontSubstitution/index.ts +++ b/packages/textkit/src/engines/fontSubstitution/index.ts @@ -1,60 +1,81 @@ import { last } from '@react-pdf/fns'; - -import empty from '../../attributedString/empty'; import { AttributedString, Run } from '../../types'; -/** - * @param run - Run - * @returns Font size - */ -const getFontSize = (run: Run) => { - return run.attributes.fontSize || 12; -}; +const IGNORED_CODE_POINTS = [173]; + +const getFontSize = (run: Run) => run.attributes.fontSize || 12; -/** - * Resolve font runs in an AttributedString, grouping equal - * runs and performing font substitution where necessary. - */ -const fontSubstitution = () => { - /** - * @param attributedString - Attributed string - * @returns Attributed string - */ - return (attributedString: AttributedString) => { - const { string, runs } = attributedString; +const pickFontFromFontStack = (codePoint, fontStack, lastFont) => { + const fontStackWithFallback = [...fontStack, lastFont]; + for (let i = 0; i < fontStackWithFallback.length; i += 1) { + const font = fontStackWithFallback[i]; + if ( + !IGNORED_CODE_POINTS.includes(codePoint) && + font && + font.hasGlyphForCodePoint && + font.hasGlyphForCodePoint(codePoint) + ) { + return font; + } + } + return fontStack.at(-1); +}; +const fontSubstitution = + () => + ({ string, runs }: AttributedString) => { let lastFont = null; + let lastFontSize = null; let lastIndex = 0; let index = 0; - const res: Run[] = []; - if (!string) return empty(); + const res: Run[] = []; - for (const run of runs) { - const fontSize = getFontSize(run); - const defaultFont = run.attributes.font; + for (let i = 0; i < runs.length; i += 1) { + const run = runs[i]; if (string.length === 0) { - res.push({ start: 0, end: 0, attributes: { font: defaultFont } }); + res.push({ + start: 0, + end: 0, + attributes: { font: run.attributes.font }, + }); break; } - for (const char of string.slice(run.start, run.end)) { - const font = defaultFont; + const chars = string.slice(run.start, run.end); + + for (let j = 0; j < chars.length; j += 1) { + const char = chars[j]; + const codePoint = char.codePointAt(0); + // If the default font does not have a glyph and the fallback font does, we use it + const font = pickFontFromFontStack( + codePoint, + run.attributes.font, + lastFont, + ); - if (font !== lastFont) { + const fontSize = getFontSize(run); + + // If anything that would impact res has changed, update it + if ( + font !== lastFont || + fontSize !== lastFontSize || + font.unitsPerEm !== lastFont.unitsPerEm + ) { if (lastFont) { res.push({ start: lastIndex, end: index, attributes: { font: lastFont, - scale: lastFont ? fontSize / lastFont.unitsPerEm : 0, + scale: lastFontSize / lastFont.unitsPerEm, }, }); } lastFont = font; + lastFontSize = fontSize; lastIndex = index; } @@ -70,13 +91,12 @@ const fontSubstitution = () => { end: string.length, attributes: { font: lastFont, - scale: lastFont ? fontSize / lastFont.unitsPerEm : 0, + scale: fontSize / lastFont.unitsPerEm, }, }); } - return { string, runs: res }; + return { string, runs: res } as AttributedString; }; -}; export default fontSubstitution; diff --git a/packages/textkit/tests/engines/fontSubstitution.test.ts b/packages/textkit/tests/engines/fontSubstitution.test.ts index 5eaaf6ff9..90a0fea42 100644 --- a/packages/textkit/tests/engines/fontSubstitution.test.ts +++ b/packages/textkit/tests/engines/fontSubstitution.test.ts @@ -1,50 +1,119 @@ import { describe, expect, test } from 'vitest'; -import empty from '../../src/attributedString/empty'; +import FontStore from '@react-pdf/font'; + import fontSubstitution from '../../src/engines/fontSubstitution'; const instance = fontSubstitution(); +const fontStore = new FontStore(); + describe('FontSubstitution', () => { test('should return empty array if no runs passed', () => { - const string = instance(empty()); + const string = instance({ string: '', runs: [] }); expect(string).toHaveProperty('runs', []); expect(string).toHaveProperty('string', ''); }); - test('should return empty array for empty string', () => { - const run = { start: 0, end: 0, attributes: {} }; - const string = instance({ string: '', runs: [run] }); + test('should merge consecutive runs with same font', () => { + const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; - expect(string).toHaveProperty('runs', []); - expect(string).toHaveProperty('string', ''); - }); + const run1 = { + start: 0, + end: 3, + attributes: { font: [helvetica] }, + } as any; + + const run2 = { + start: 3, + end: 5, + attributes: { font: [helvetica] }, + } as any; - test('should merge consecutive runs with same font', () => { - const font = { ascent: 10, unitsPerEm: 2 }; - const run1 = { start: 0, end: 3, attributes: { font } }; - const run2 = { start: 3, end: 5, attributes: { font } }; const string = instance({ string: 'Lorem', runs: [run1, run2] }); expect(string).toHaveProperty('string', 'Lorem'); expect(string.runs).toHaveLength(1); expect(string.runs[0]).toHaveProperty('start', 0); expect(string.runs[0]).toHaveProperty('end', 5); + expect(string.runs[0].attributes.font).toBeTruthy(); }); test('should substitute many runs', () => { - const font1 = { ascent: 10, unitsPerEm: 2 }; - const font2 = { ascent: 8, unitsPerEm: 3 }; - const run1 = { start: 0, end: 3, attributes: { font: font1 } }; - const run2 = { start: 3, end: 5, attributes: { font: font2 } }; + const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; + const helveticaBold = fontStore.getFont({ + fontFamily: 'Helvetica', + fontWeight: 700, + }).data; + + const run1 = { + start: 0, + end: 3, + attributes: { font: [helveticaBold] }, + } as any; + + const run2 = { start: 3, end: 5, attributes: { font: [helvetica] } } as any; + const string = instance({ string: 'Lorem', runs: [run1, run2] }); expect(string).toHaveProperty('string', 'Lorem'); expect(string.runs).toHaveLength(2); expect(string.runs[0]).toHaveProperty('start', 0); expect(string.runs[0]).toHaveProperty('end', 3); + expect(string.runs[0].attributes.font).toBeTruthy(); expect(string.runs[1]).toHaveProperty('start', 3); expect(string.runs[1]).toHaveProperty('end', 5); + expect(string.runs[1].attributes.font).toBeTruthy(); + }); + + describe('Fallback Font', () => { + const SimplifiedChineseFont = { + name: 'SimplifiedChineseFont', + hasGlyphForCodePoint: (codePoint) => codePoint === 20320, + }; + + test('should utilize a fallback font that supports the provided glyph', () => { + const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; + + const run = { + start: 0, + end: 1, + attributes: { + font: [helvetica, SimplifiedChineseFont], + }, + } as any; + + const string = instance({ string: '你', runs: [run] }); + + expect(string).toHaveProperty('string', '你'); + expect(string.runs).toHaveLength(1); + expect(string.runs[0]).toHaveProperty('start', 0); + expect(string.runs[0]).toHaveProperty('end', 1); + expect(string.runs[0].attributes.font).toBe(SimplifiedChineseFont); + }); + + test('should split a run when fallback font is used on a portion of the run', () => { + const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data; + + const run = { + start: 0, + end: 2, + attributes: { + font: [helvetica, SimplifiedChineseFont], + }, + } as any; + + const string = instance({ string: 'A你', runs: [run] }); + + expect(string).toHaveProperty('string', 'A你'); + expect(string.runs).toHaveLength(2); + expect(string.runs[0]).toHaveProperty('start', 0); + expect(string.runs[0]).toHaveProperty('end', 1); + expect(string.runs[0].attributes.font).toBeTruthy(); + expect(string.runs[1]).toHaveProperty('start', 1); + expect(string.runs[1]).toHaveProperty('end', 2); + expect(string.runs[1].attributes.font).toBe(SimplifiedChineseFont); + }); }); });