Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strip-default-ignorable-chars.md
Original file line number Diff line number Diff line change
@@ -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
110 changes: 95 additions & 15 deletions packages/layout/src/text/ignoreChars.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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, ''),
Expand Down
45 changes: 45 additions & 0 deletions packages/layout/tests/text/ignoreChars.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Loading