Skip to content
Open
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/fuzzy-hounds-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-pdf/textkit": patch
---

fix(textkit): preserve variation selector font runs
11 changes: 9 additions & 2 deletions packages/textkit/src/engines/fontSubstitution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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];

Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/textkit/tests/engines/fontSubstitution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down