diff --git a/.changeset/strip-default-ignorable-chars.md b/.changeset/strip-default-ignorable-chars.md new file mode 100644 index 000000000..90de3f920 --- /dev/null +++ b/.changeset/strip-default-ignorable-chars.md @@ -0,0 +1,5 @@ +--- +"@react-pdf/layout": patch +--- + +fix(layout): strip default-ignorable characters (bidi controls, zero-width chars, BOM) so they are not rendered as fallback `.notdef` glyphs diff --git a/packages/layout/src/text/ignoreChars.ts b/packages/layout/src/text/ignoreChars.ts index cb4f24384..b103333bb 100644 --- a/packages/layout/src/text/ignoreChars.ts +++ b/packages/layout/src/text/ignoreChars.ts @@ -1,27 +1,107 @@ import { Font, Fragment } from '@react-pdf/textkit'; -const IGNORABLE_CODEPOINTS = [ - 8232, // LINE_SEPARATOR - 8233, // PARAGRAPH_SEPARATOR - 8288, // WORD_JOINER -]; - -const buildSubsetForFont = (font: Font) => - IGNORABLE_CODEPOINTS.reduce((acc, codePoint) => { +// Separators historically stripped by this module. They are not +// Default_Ignorable_Code_Points but should keep being removed to avoid a +// visible-glyph regression. +const EXTRA_IGNORABLE_CODEPOINTS = new Set([ + 0x2028, // LINE_SEPARATOR + 0x2029, // PARAGRAPH_SEPARATOR +]); + +/** + * Whether a code point is a Unicode Default_Ignorable_Code_Point. + * + * These characters (bidi controls, zero-width spaces/joiners, the BOM, variation + * selectors, etc.) are not meant to be rendered as visible glyphs. This mirrors + * fontkit's own `isDefaultIgnorable` (derived from DerivedCoreProperties.txt), + * so text laid out here matches what the shaper considers ignorable. + */ +const isDefaultIgnorableCodePoint = (ch: number): boolean => { + const plane = ch >> 16; + if (plane === 0) { + // BMP + switch (ch >> 8) { + case 0x00: + return ch === 0x00ad; + case 0x03: + return ch === 0x034f; + case 0x06: + return ch === 0x061c; + case 0x17: + return ch >= 0x17b4 && ch <= 0x17b5; + case 0x18: + return ch >= 0x180b && ch <= 0x180e; + case 0x20: + return ( + (ch >= 0x200b && ch <= 0x200f) || + (ch >= 0x202a && ch <= 0x202e) || + (ch >= 0x2060 && ch <= 0x206f) + ); + case 0xfe: + return (ch >= 0xfe00 && ch <= 0xfe0f) || ch === 0xfeff; + case 0xff: + return ch >= 0xfff0 && ch <= 0xfff8; + default: + return false; + } + } + switch (plane) { + case 0x01: + return ( + (ch >= 0x1bca0 && ch <= 0x1bca3) || (ch >= 0x1d173 && ch <= 0x1d17a) + ); + case 0x0e: + return ch >= 0xe0000 && ch <= 0xe0fff; + default: + return false; + } +}; + +/** + * Removes default-ignorable characters that the fragment's font cannot render. + * + * Without this, characters such as the bidi controls U+202A/U+202C or the RLM + * U+200F reach glyph mapping, miss the font's cmap, and get drawn as a fallback + * `.notdef` glyph — appearing as visible garbage (e.g. overprinted on an adjacent + * letter). If the font does provide a glyph for a given code point we keep it, so + * fonts that intentionally render these are unaffected. + */ +const buildIgnorableChars = (font: Font, string: string): string[] => { + const chars = new Set(); + for (const char of string) { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) continue; + if ( + !isDefaultIgnorableCodePoint(codePoint) && + !EXTRA_IGNORABLE_CODEPOINTS.has(codePoint) + ) + continue; if ( font && font.hasGlyphForCodePoint && font.hasGlyphForCodePoint(codePoint) - ) { - return acc; - } - return [...acc, String.fromCharCode(codePoint)]; - }, []); + ) + continue; + chars.add(char); + } + return [...chars]; +}; + +const escapeRegExp = (char: string): string => + char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const ignoreChars = (fragments: Fragment[]): Fragment[] => fragments.map((fragment) => { - const charSubset = buildSubsetForFont(fragment.attributes.font[0]); - const subsetRegex = new RegExp(charSubset.join('|')); + const ignorable = buildIgnorableChars( + fragment.attributes.font[0], + fragment.string, + ); + + if (ignorable.length === 0) { + return fragment; + } + + const subsetRegex = new RegExp(ignorable.map(escapeRegExp).join('|'), 'gu'); return { string: fragment.string.replace(subsetRegex, ''), diff --git a/packages/layout/tests/text/ignoreChars.test.ts b/packages/layout/tests/text/ignoreChars.test.ts new file mode 100644 index 000000000..74ae88a85 --- /dev/null +++ b/packages/layout/tests/text/ignoreChars.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest'; + +import type { Font, Fragment } from '@react-pdf/textkit'; + +import ignoreChars from '../../src/text/ignoreChars'; + +const fontWithout = (codePoints: number[] = []): Font => + ({ + hasGlyphForCodePoint: (codePoint: number) => codePoints.includes(codePoint), + }) as unknown as Font; + +const fragment = (string: string, font: Font): Fragment => + ({ string, attributes: { font: [font] } }) as unknown as Fragment; + +const run = (string: string, font: Font = fontWithout()): string => + ignoreChars([fragment(string, font)])[0].string; + +describe('layout text ignoreChars', () => { + test('strips bidi controls the font cannot render (LRE, PDF, RLM)', () => { + expect(run('\u202aExample Name\u202c\u200f')).toBe('Example Name'); + }); + + test('strips zero-width, word joiner, isolates and BOM', () => { + expect(run('\uFEFF\u200bA\u200cB\u2060C\u2066D\u2069')).toBe('ABCD'); + }); + + test('keeps the previously handled separators and word joiner', () => { + expect(run('a\u2028b\u2029c\u2060d')).toBe('abcd'); + }); + + test('leaves ordinary and accented text untouched', () => { + expect(run('Æblegård Müller 日本語')).toBe('Æblegård Müller 日本語'); + }); + + test('keeps a default-ignorable code point when the font provides a glyph', () => { + // 0x2060 WORD JOINER present in the font -> not stripped. + expect(run('a\u2060b', fontWithout([0x2060]))).toBe('a\u2060b'); + }); + + test('returns the fragment unchanged when nothing is ignorable', () => { + const font = fontWithout(); + const input = [fragment('plain', font)]; + expect(ignoreChars(input)[0]).toBe(input[0]); + }); +});