diff --git a/.changeset/wise-clocks-drop.md b/.changeset/wise-clocks-drop.md new file mode 100644 index 000000000..cadc10110 --- /dev/null +++ b/.changeset/wise-clocks-drop.md @@ -0,0 +1,5 @@ +--- +"@react-pdf/textkit": minor +--- + +fix(textkit): advanceWidthBetween regression with glyphIndices diff --git a/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-cjk-text-at-character-boundaries-1-snap.png b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-cjk-text-at-character-boundaries-1-snap.png new file mode 100644 index 000000000..83cb83830 Binary files /dev/null and b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-cjk-text-at-character-boundaries-1-snap.png differ diff --git a/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-mixed-cjk-and-latin-text-1-snap.png b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-mixed-cjk-and-latin-text-1-snap.png new file mode 100644 index 000000000..ab4525e4d Binary files /dev/null and b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-mixed-cjk-and-latin-text-1-snap.png differ diff --git a/packages/renderer/tests/text.test.jsx b/packages/renderer/tests/text.test.jsx index 34e782e73..074bc5d8c 100644 --- a/packages/renderer/tests/text.test.jsx +++ b/packages/renderer/tests/text.test.jsx @@ -11,6 +11,11 @@ import { } from '@react-pdf/renderer'; import renderToImage from './renderComponent'; +Font.register({ + family: 'NotoSansJP', + src: 'https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf', +}); + const styles = StyleSheet.create({ title: { margin: 20, @@ -155,4 +160,36 @@ describe('text', () => { expect(image).toMatchImageSnapshot(); }); + + test('should wrap CJK text at character boundaries', async () => { + const image = await renderToImage( + + + + + 本当に長いテキスト + + + + , + ); + + expect(image).toMatchImageSnapshot(); + }); + + test('should wrap mixed CJK and Latin text', async () => { + const image = await renderToImage( + + + + + Hello世界!これはテストです。 + + + + , + ); + + expect(image).toMatchImageSnapshot(); + }); }); diff --git a/packages/textkit/src/run/glyphIndexAt.ts b/packages/textkit/src/run/glyphIndexAt.ts index f54c7c6e4..a64d09967 100644 --- a/packages/textkit/src/run/glyphIndexAt.ts +++ b/packages/textkit/src/run/glyphIndexAt.ts @@ -15,6 +15,14 @@ const glyphIndexAt = (index: number, run: Run) => { const glyphIndices = run?.glyphIndices; if (!glyphIndices) return index; + // If index is past all glyph mappings, return length (one past end) + if ( + glyphIndices.length > 0 && + index > glyphIndices[glyphIndices.length - 1] + ) { + return glyphIndices.length; + } + let result = index; for (let i = glyphIndices.length - 1; i >= 0; i -= 1) { diff --git a/packages/textkit/tests/attributedString/advanceWidthBetween.test.ts b/packages/textkit/tests/attributedString/advanceWidthBetween.test.ts index c6f369958..9ad6571e8 100644 --- a/packages/textkit/tests/attributedString/advanceWidthBetween.test.ts +++ b/packages/textkit/tests/attributedString/advanceWidthBetween.test.ts @@ -228,4 +228,55 @@ describe('attributeString advanceWidthBetween operator', () => { expect(advanceWidthBetween(1, 2, string)).toBe(10); }); + + test('should return correct per-character width for CJK runs split by script', () => { + // Simulates scriptItemizer splitting "本当にテキスト" into: + // run 0: "本当" (Han) + // run 1: "に" (Hiragana) + // run 2: "テキスト" (Katakana) + const pos = (w: number) => ({ + xAdvance: w, + yAdvance: 0, + xOffset: 0, + yOffset: 0, + }); + const string = { + string: '本当にテキスト', + runs: [ + { + start: 0, + end: 2, + attributes: {}, + glyphIndices: [0, 1], + positions: [pos(12), pos(12)], + }, + { + start: 2, + end: 3, + attributes: {}, + glyphIndices: [0], + positions: [pos(12)], + }, + { + start: 3, + end: 7, + attributes: {}, + glyphIndices: [0, 1, 2, 3], + positions: [pos(12), pos(11), pos(12), pos(12)], + }, + ], + }; + + // Each character should return its own width, not 0 + expect(advanceWidthBetween(0, 1, string)).toBe(12); // 本 + expect(advanceWidthBetween(1, 2, string)).toBe(12); // 当 + expect(advanceWidthBetween(2, 3, string)).toBe(12); // に + expect(advanceWidthBetween(3, 4, string)).toBe(12); // テ + expect(advanceWidthBetween(4, 5, string)).toBe(11); // キ + expect(advanceWidthBetween(5, 6, string)).toBe(12); // ス + expect(advanceWidthBetween(6, 7, string)).toBe(12); // ト + + // Cross-run range + expect(advanceWidthBetween(0, 7, string)).toBe(83); + }); }); diff --git a/packages/textkit/tests/run/advanceWidthBetween.test.ts b/packages/textkit/tests/run/advanceWidthBetween.test.ts index 5e024ea5f..e1c19db15 100644 --- a/packages/textkit/tests/run/advanceWidthBetween.test.ts +++ b/packages/textkit/tests/run/advanceWidthBetween.test.ts @@ -129,4 +129,43 @@ describe('run advanceWidthBetween operator', () => { expect(advanceWidthBetween(7, 9, run)).toBe(32); }); + + test('should return correct width for each character with glyphIndices', () => { + // Simulates a CJK run where scriptItemizer splits by script + // e.g., "本当" as a Han run with 1:1 glyph mapping + const run = { + start: 0, + end: 2, + attributes: {}, + glyphIndices: [0, 1], + positions: [ + { xAdvance: 12, yAdvance: 0, xOffset: 0, yOffset: 0 }, + { xAdvance: 12, yAdvance: 0, xOffset: 0, yOffset: 0 }, + ], + }; + + expect(advanceWidthBetween(0, 1, run)).toBe(12); + expect(advanceWidthBetween(1, 2, run)).toBe(12); + expect(advanceWidthBetween(0, 2, run)).toBe(24); + }); + + test('should return correct width for last character with glyphIndices and offset start', () => { + // Run starting at offset, like a Katakana run after Han+Hiragana runs + const run = { + start: 3, + end: 7, + attributes: {}, + glyphIndices: [0, 1, 2, 3], + positions: [ + { xAdvance: 12, yAdvance: 0, xOffset: 0, yOffset: 0 }, + { xAdvance: 11, yAdvance: 0, xOffset: 0, yOffset: 0 }, + { xAdvance: 12, yAdvance: 0, xOffset: 0, yOffset: 0 }, + { xAdvance: 12, yAdvance: 0, xOffset: 0, yOffset: 0 }, + ], + }; + + expect(advanceWidthBetween(3, 4, run)).toBe(12); + expect(advanceWidthBetween(6, 7, run)).toBe(12); + expect(advanceWidthBetween(3, 7, run)).toBe(47); + }); }); diff --git a/packages/textkit/tests/run/glyphIndexAt.test.ts b/packages/textkit/tests/run/glyphIndexAt.test.ts index 75d8283ff..f45f1329c 100644 --- a/packages/textkit/tests/run/glyphIndexAt.test.ts +++ b/packages/textkit/tests/run/glyphIndexAt.test.ts @@ -81,4 +81,28 @@ describe('run glyphIndexAt operator', () => { expect(glyphIndexAt(0, run)).toBe(0); expect(glyphIndexAt(1, run)).toBe(2); }); + + test('should return length for index past all glyph mappings', () => { + const run = { + start: 0, + end: 2, + attributes: {}, + glyphIndices: [0, 1], + }; + + expect(glyphIndexAt(2, run)).toBe(2); + expect(glyphIndexAt(5, run)).toBe(2); + }); + + test('should return length for index past ligature mappings', () => { + const run = { + start: 0, + end: 5, + attributes: {}, + glyphIndices: [0, 1, 2, 4], + }; + + expect(glyphIndexAt(5, run)).toBe(4); + expect(glyphIndexAt(10, run)).toBe(4); + }); });