From e767c5cbb5988f34c52b544bc8286867d3d97b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sun, 12 Apr 2026 16:22:06 +0900 Subject: [PATCH 1/3] fix(textkit): advanceWidthBetween regression with glyphIndices Adapt advanceWidthBetween to new glyphIndexAt semantics from #3358. The refactored glyphIndexAt uses reverse-loop search instead of direct array lookup, requiring callers to use `end - 1` (as slice.ts was updated). advanceWidthBetween was missed, causing width=0 for characters at run boundaries when scriptItemizer splits CJK text by script (Han/Hiragana/ Katakana). This broke text wrapping for any multi-script text. --- ...jk-text-at-character-boundaries-1-snap.png | Bin 0 -> 2710 bytes ...d-wrap-mixed-cjk-and-latin-text-1-snap.png | Bin 0 -> 3488 bytes packages/renderer/tests/text.test.jsx | 37 +++++++++++++ .../textkit/src/run/advanceWidthBetween.ts | 2 +- .../advanceWidthBetween.test.ts | 51 ++++++++++++++++++ .../tests/run/advanceWidthBetween.test.ts | 39 ++++++++++++++ 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-cjk-text-at-character-boundaries-1-snap.png create mode 100644 packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-wrap-mixed-cjk-and-latin-text-1-snap.png 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 0000000000000000000000000000000000000000..83cb838303f09a51804516e4b1ec0e047b25f93f GIT binary patch literal 2710 zcmdT``8U-49{)0yA?qkSGGiH}WG!UN(1?i4n8#MenxP(Lh-7DoNs`+ll`LIL<{=Z4 z@gP|W8C)SnjG>I3$ZqO>?mhR1=Q;QO1NVE*_xm~D^F8PNeZSwY<^85QJK2hhDu_Z5 zB!0>ciw9>1xTD}g;92{(#yM~Zgj=5?z`+#@4@d{^eBpRoYpAkEaR!1!F{iLrgs8_0 zqgPMM{Di&zR$3#m7e`JsleT&jL>5rQ4qRxHw#h_aq88w8$|#Bq+uhV6XBS8dC+{(o zrfQL`^hEhRGwr>CE|x39H4Km4Uf+=Xela1$@~(Pvua-V1dT26SZtVWt=ss#+(9z`d z-^0r0_@sE<^6+d3rh@QM#`24$*d-=OiRekG@(Ee1y~d;q1gz_9u6%WvUfUiH8v zZ1zM&M}Jt;D-)fUz&;=HAAg<1FncI;NK1=WnTGkb&6}Ps@IM14d5U0ZgA=@+jY!>} zi=$>ijNxHh=IZFyu8ex}T3Xuf%o*dD&qG6UOJj|c>gpp`Op)i#og1H?hTqNms;i@OokpVt zZxt1()`X16%ITad3pEc84&Gemz)GC*JpD)P#7{6_*M97p+}A!ExceJr7J>e7HG~KrkJs~SgJ3Ek=>M4cp}X;T8z*%I9>0aot^#Q7+H0%s9?$I+~oJ~@rq`VEWbLN z4B1?w;qSZ=CK(<7)4aZ+!TieXKxd`C`Lz`R`(gy0PB+Tw?CPT6pQYFr^FvG~b9|NC zYm}3Jgd~ALAm+D!3{(w#wAu#972uzR&b(Kj|6C`7pbC(B9yeF{wl2>N>1KFS0SrEK zCy)$JZPUp4pocmK4<=D4u!)HYy@VgJYHDgeQ>v(r~h z1%!n7&$p{x9&6Coe%==}4oIQl_z;5L-a=;7g>zklo1A&B3mAKQ`vStD?7MeUyy_!W z)YSz|OiV!Mqjop-UtVV4ikSPLf=0tY7lVdt4m!)GH~Uom;gorj(o7NlX>6A(#_h># z1x&{|J0~{AYz&KGb#hMVZXW{#yF=njTyUTQ1tXEXJJ7c zT3%j${mA&Tv%5Q7PEKxqtZ{!>onmuyGn9BdZW~kD|LavZoCRS4)01y0S>c*dQ*$)s z`t^y0k=l~0^cU6DAVy}Iq>}2fWAY**BJNc_e2~xTRJYL(5bc?2x8%r$VHJ=Dl}ZKm zY;Jx->ku2@2?+_)YtcbR752)>p_P?i&u*Xd@*;08`)+P-`mD_k+BrB-POq~|x&jPX zd1i9HzP|2sKSAICg?&M+Dejjqg#RQJ*!v{&Q#A>8UYHG4g^UQLw^A6;6>7ReMm$nLgmAtX&#d(uz z8NZ9MvPya*n9&r^YpEvnD(7^CRPL~Hj~KPv5VB28aGPe{-rn;gwUW8HxxgsXoJvp3 zeW;L@l@0XNu(SjjwoELxg3dI=ZXxRG>Riv5o0^yudQ=A{1elfimeVn@v9aTmlm0_5 z0Pf7V9iu1a*Zw8i01^qUsR@S(OJHj+jj|SPY(F`A=n@;M+9?G$fiiv6M?UfXkP}HlZK2sGG6ila5 ztpWo}LQNQ+nH6ViTi9$R02{AnRjj^-y816@G&+-ykMAUO($>~?vcQUR6EsSG|Ne`sbuk-@hW5qy zr=As+p^E(_jz=8X4cGdoXJ&%ML`7f3oV{RuejUm4y6{!m0??00BpPvrg@w^tT6=)d z(rRms%`Gf&?(XRz&ZpgF0Z30?tYpUo@8ZH)TFP=Mb;_;mV5~h31NDJ1vbD7}_|bGb zXCO)$q}JSd=Ky(UJ>*eWTU*-$$NgeLV4{7P(q?Mz$b=Ra7H+OD?7#Gx$wwlQ9v_cW zxTE3N^0h0cBECWLhpyCJB(wH1`_=mqo1Y~L7DAof-EY7UqDOu6tIpba7Ahqn0qmn{ zyY$_=C6bas0GywnKa|^p?C1J}StdI2GNkoO=bi3F=y1EgczLBljG(@}*hjj*a7#01x$#j|&FhfW;CzW#fdcwDw8Y`2YX_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ab4525e4d612a9f2feef0d1a057d18263095a3e9 GIT binary patch literal 3488 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 literal 0 HcmV?d00001 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/advanceWidthBetween.ts b/packages/textkit/src/run/advanceWidthBetween.ts index b88748cae..8c505c8c6 100644 --- a/packages/textkit/src/run/advanceWidthBetween.ts +++ b/packages/textkit/src/run/advanceWidthBetween.ts @@ -13,7 +13,7 @@ import { Run } from '../types'; const advanceWidthBetween = (start: number, end: number, run: Run) => { const runStart = run.start || 0; const glyphStartIndex = Math.max(0, glyphIndexAt(start - runStart, run)); - const glyphEndIndex = Math.max(0, glyphIndexAt(end - runStart, run)); + const glyphEndIndex = Math.max(0, glyphIndexAt(end - runStart - 1, run) + 1); const positions = (run.positions || []).slice(glyphStartIndex, glyphEndIndex); return positionsAdvanceWidth(positions); 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); + }); }); From 7e5073c247360daa276a642a4648dfaa9d29f526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sun, 12 Apr 2026 22:18:31 +0900 Subject: [PATCH 2/3] add changeset --- .changeset/wise-clocks-drop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wise-clocks-drop.md 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 From 1c487327356e6522f152f4bbfcf489bf70960caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Sun, 12 Apr 2026 22:40:20 +0900 Subject: [PATCH 3/3] fix(textkit): handle past-the-end index in glyphIndexAt glyphIndexAt now returns glyphIndices.length when the string index is beyond all glyph mappings, restoring the old fallback behavior. This removes the need for callers to pass end - 1 as a workaround. advanceWidthBetween is reverted to its original calling convention. --- .../textkit/src/run/advanceWidthBetween.ts | 2 +- packages/textkit/src/run/glyphIndexAt.ts | 8 +++++++ .../textkit/tests/run/glyphIndexAt.test.ts | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/textkit/src/run/advanceWidthBetween.ts b/packages/textkit/src/run/advanceWidthBetween.ts index 8c505c8c6..b88748cae 100644 --- a/packages/textkit/src/run/advanceWidthBetween.ts +++ b/packages/textkit/src/run/advanceWidthBetween.ts @@ -13,7 +13,7 @@ import { Run } from '../types'; const advanceWidthBetween = (start: number, end: number, run: Run) => { const runStart = run.start || 0; const glyphStartIndex = Math.max(0, glyphIndexAt(start - runStart, run)); - const glyphEndIndex = Math.max(0, glyphIndexAt(end - runStart - 1, run) + 1); + const glyphEndIndex = Math.max(0, glyphIndexAt(end - runStart, run)); const positions = (run.positions || []).slice(glyphStartIndex, glyphEndIndex); return positionsAdvanceWidth(positions); 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/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); + }); });