From 01d97f1fd67ebd6edbf0e724c4b303429e5ab22a Mon Sep 17 00:00:00 2001 From: to-na <43640969+to-na@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:53:46 +0900 Subject: [PATCH] fix(textkit): preserve variation selector font runs --- .changeset/fuzzy-hounds-smile.md | 5 +++ .../src/engines/fontSubstitution/index.ts | 11 +++++-- .../tests/engines/fontSubstitution.test.ts | 32 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/fuzzy-hounds-smile.md diff --git a/.changeset/fuzzy-hounds-smile.md b/.changeset/fuzzy-hounds-smile.md new file mode 100644 index 000000000..534bd2222 --- /dev/null +++ b/.changeset/fuzzy-hounds-smile.md @@ -0,0 +1,5 @@ +--- +"@react-pdf/textkit": patch +--- + +fix(textkit): preserve variation selector font runs diff --git a/packages/textkit/src/engines/fontSubstitution/index.ts b/packages/textkit/src/engines/fontSubstitution/index.ts index 8e7408c8a..c4062ba93 100644 --- a/packages/textkit/src/engines/fontSubstitution/index.ts +++ b/packages/textkit/src/engines/fontSubstitution/index.ts @@ -3,6 +3,13 @@ import { AttributedString, Font, Run } from '../../types'; const IGNORED_CODE_POINTS = [173]; // U+00AD Soft Hyphen +// Unicode Variation_Selector property ranges. +const isVariationSelector = (codePoint: number) => + (codePoint >= 0x180b && codePoint <= 0x180d) || + codePoint === 0x180f || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + (codePoint >= 0xe0100 && codePoint <= 0xe01ef); + const getFontSize = (run: Run) => run.attributes.fontSize || 12; const pickFontFromFontStack = ( @@ -11,6 +18,7 @@ const pickFontFromFontStack = ( lastFont?: Font, ) => { if (IGNORED_CODE_POINTS.includes(codePoint)) return lastFont; + if (isVariationSelector(codePoint) && lastFont) return lastFont; const fontStackWithFallback = [...fontStack, lastFont]; @@ -52,8 +60,7 @@ const fontSubstitution = const chars = string.slice(run.start, run.end); - for (let j = 0; j < chars.length; j += 1) { - const char = chars[j]; + for (const char of chars) { const codePoint = char.codePointAt(0); // If the default font does not have a glyph and the fallback font does, we use it diff --git a/packages/textkit/tests/engines/fontSubstitution.test.ts b/packages/textkit/tests/engines/fontSubstitution.test.ts index 0a76efc8f..cded1d165 100644 --- a/packages/textkit/tests/engines/fontSubstitution.test.ts +++ b/packages/textkit/tests/engines/fontSubstitution.test.ts @@ -73,6 +73,38 @@ describe('FontSubstitution', () => { hasGlyphForCodePoint: (codePoint) => codePoint === 20320, }; + test.each([ + ['Mongolian free variation selector', '\u1820', '\u180B'], + ['Mongolian free variation selector four', '\u1820', '\u180F'], + ['BMP variation selector', '\u6F22', '\uFE00'], + ['supplementary variation selector', '\u6F22', '\u{E0100}'], + ])('should preserve the preceding font for a %s', (_, base, selector) => { + const baseCodePoint = base.codePointAt(0); + const baseFont = { + name: 'BaseFont', + unitsPerEm: 1000, + hasGlyphForCodePoint: (codePoint) => codePoint === baseCodePoint, + }; + const fallbackFont = { + name: 'FallbackFont', + unitsPerEm: 1000, + hasGlyphForCodePoint: () => true, + }; + const value = `${base}${selector}`; + const run = { + start: 0, + end: value.length, + attributes: { font: [baseFont, fallbackFont] }, + } as any; + + const result = instance({ string: value, runs: [run] }); + + expect(result.runs).toHaveLength(1); + expect(result.runs[0]).toHaveProperty('start', 0); + expect(result.runs[0]).toHaveProperty('end', value.length); + expect(result.runs[0].attributes.font).toEqual([baseFont]); + }); + test('should utilize a fallback font that supports the provided glyph', () => { const helvetica = fontStore.getFont({ fontFamily: 'Helvetica' }).data;