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);
+ });
});