Skip to content

Commit c093c78

Browse files
fix(textkit): preserve empty glyph codepoints
1 parent d41a820 commit c093c78

3 files changed

Lines changed: 167 additions & 4 deletions

File tree

.changeset/sharp-mugs-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-pdf/textkit': patch
3+
---
4+
5+
fix(textkit): preserve glyph code points when fontkit returns an empty mapping

packages/textkit/src/layout/generateGlyphs.ts

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,116 @@
11
import scale from '../run/scale';
22
import resolveStringIndices from '../string-indices/resolve';
33
import resolveGlyphIndices from '../glyph-indices/resolve';
4-
import { AttributedString, Position, Run } from '../types';
4+
import { AttributedString, Glyph, Position, Run } from '../types';
5+
6+
const codePointsFromString = (string: string): number[] => {
7+
const result: number[] = [];
8+
9+
for (const char of string) {
10+
const codePoint = char.codePointAt(0);
11+
12+
if (codePoint !== undefined) result.push(codePoint);
13+
}
14+
15+
return result;
16+
};
17+
18+
const sequenceStartsAt = (
19+
values: number[],
20+
sequence: number[],
21+
start: number,
22+
) => sequence.every((value, index) => values[start + index] === value);
23+
24+
const findSequence = (values: number[], sequence: number[], start: number) => {
25+
if (sequence.length === 0) return start;
26+
27+
for (let i = start; i <= values.length - sequence.length; i += 1) {
28+
if (sequenceStartsAt(values, sequence, i)) return i;
29+
}
30+
31+
return -1;
32+
};
33+
34+
const cloneGlyph = (glyph: Glyph, codePoints: number[]): Glyph =>
35+
Object.assign(Object.create(Object.getPrototypeOf(glyph)), glyph, {
36+
codePoints,
37+
});
38+
39+
const assignPendingCodePoints = (
40+
glyphs: Glyph[],
41+
pendingGlyphs: number[],
42+
codePoints: number[],
43+
) => {
44+
if (pendingGlyphs.length === 0 || codePoints.length === 0) return;
45+
46+
let codePointIndex = 0;
47+
48+
for (let i = 0; i < pendingGlyphs.length; i += 1) {
49+
const remainingGlyphs = pendingGlyphs.length - i;
50+
const remainingCodePoints = codePoints.length - codePointIndex;
51+
const length = Math.max(1, remainingCodePoints - remainingGlyphs + 1);
52+
const glyphIndex = pendingGlyphs[i];
53+
54+
glyphs[glyphIndex] = cloneGlyph(
55+
glyphs[glyphIndex],
56+
codePoints.slice(codePointIndex, codePointIndex + length),
57+
);
58+
59+
codePointIndex += length;
60+
}
61+
};
62+
63+
const normalizeGlyphCodePoints = (glyphs: Glyph[], string: string): Glyph[] => {
64+
const codePoints = codePointsFromString(string);
65+
const result = [...glyphs];
66+
const pendingGlyphs: number[] = [];
67+
let isAligned = true;
68+
let cursor = 0;
69+
70+
for (let i = 0; i < glyphs.length; i += 1) {
71+
const glyph = glyphs[i];
72+
const glyphCodePoints = glyph.codePoints || [];
73+
74+
if (!isAligned) continue;
75+
76+
if (glyphCodePoints.length === 0) {
77+
pendingGlyphs.push(i);
78+
continue;
79+
}
80+
81+
if (pendingGlyphs.length > 0) {
82+
const foundIndex = findSequence(codePoints, glyphCodePoints, cursor);
83+
84+
if (foundIndex < cursor) {
85+
pendingGlyphs.length = 0;
86+
isAligned = false;
87+
continue;
88+
}
89+
90+
assignPendingCodePoints(
91+
result,
92+
pendingGlyphs,
93+
codePoints.slice(cursor, foundIndex),
94+
);
95+
96+
pendingGlyphs.length = 0;
97+
cursor = foundIndex + glyphCodePoints.length;
98+
continue;
99+
}
100+
101+
if (sequenceStartsAt(codePoints, glyphCodePoints, cursor)) {
102+
cursor += glyphCodePoints.length;
103+
} else {
104+
isAligned = false;
105+
}
106+
}
107+
108+
if (isAligned) {
109+
assignPendingCodePoints(result, pendingGlyphs, codePoints.slice(cursor));
110+
}
111+
112+
return result;
113+
};
5114

6115
const getCharacterSpacing = (run: Run) => {
7116
return run.attributes?.characterSpacing || 0;
@@ -67,16 +176,17 @@ const layoutRun = (string: string) => {
67176
'ltr',
68177
);
69178

179+
const glyphs = normalizeGlyphCodePoints(glyphRun.glyphs, runString);
70180
const positions = scalePositions(run, glyphRun.positions);
71-
const stringIndices = resolveStringIndices(glyphRun.glyphs);
72-
const glyphIndices = resolveGlyphIndices(glyphRun.glyphs);
181+
const stringIndices = resolveStringIndices(glyphs);
182+
const glyphIndices = resolveGlyphIndices(glyphs);
73183

74184
const result: Run = {
75185
...run,
76186
positions,
77187
stringIndices,
78188
glyphIndices,
79-
glyphs: glyphRun.glyphs,
189+
glyphs,
80190
};
81191

82192
return result;

packages/textkit/tests/layout/generateGlyphs.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,54 @@ describe('generateGlyphs', () => {
5353
]);
5454
});
5555

56+
test('should preserve source code points when a glyph has empty code points', () => {
57+
const glyphWithoutCodePoints = {
58+
id: 27927,
59+
codePoints: [],
60+
advanceWidth: 8,
61+
};
62+
63+
const fontWithEmptyCodePoints = {
64+
...font,
65+
layout: () => {
66+
const glyphs = [
67+
glyphWithoutCodePoints,
68+
{ id: 27700, codePoints: [27700], advanceWidth: 8 },
69+
];
70+
71+
return {
72+
glyphs,
73+
positions: glyphs.map((glyph) => ({
74+
xAdvance: glyph.advanceWidth,
75+
yAdvance: 0,
76+
xOffset: 0,
77+
yOffset: 0,
78+
})),
79+
};
80+
},
81+
};
82+
83+
const result = instance({
84+
string: '洗水',
85+
runs: [
86+
{
87+
start: 0,
88+
end: 2,
89+
attributes: { font: [fontWithEmptyCodePoints], fontSize: 2 },
90+
},
91+
],
92+
});
93+
94+
expect(result.runs[0].stringIndices).toEqual([0, 1]);
95+
expect(result.runs[0].glyphIndices).toEqual([0, 1]);
96+
expect(result.runs[0].glyphs?.map((glyph) => glyph.codePoints)).toEqual([
97+
[27927],
98+
[27700],
99+
]);
100+
expect(result.runs[0].glyphs?.[0]).not.toBe(glyphWithoutCodePoints);
101+
expect(glyphWithoutCodePoints.codePoints).toEqual([]);
102+
});
103+
56104
test('should return correctly generate multi-run simple string glyphs', () => {
57105
const result = instance({
58106
string: 'Lorem',

0 commit comments

Comments
 (0)