textkit 7.0.1 regression: multi-child <Text> loses font substitution at fragment boundaries ({value}% renders tofu)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- react, typescript
- Domain
- frontend
Research direction
Start with lib/textkit.js, reading flatten and preprocessRuns alongside the 7.0.0 to 7.0.1 changes. Run repro.mjs with the provided renderer versions and compare the generated PDFs. Done means fragmented and single-string text render identically, with substituted fonts handling missing punctuation instead of tofu boxes.
Written by the indexing model from the issue text.
Description
Describe the bug
Since @react-pdf/textkit 7.0.1, a <Text> with multiple children (fragments — e.g. {value}% or {label}: {value} in JSX) renders characters at fragment boundaries with the wrong font: the raw fontFamily stack's first font is used instead of the per-codepoint substituted font. When that first font lacks the glyph (e.g. % or : missing from a script-specific font), the character renders as a tofu box. 7.0.0 renders the same document correctly, so this bisects cleanly to 7.0.1's flatten/preprocess rework ("merge run attributes lazily in flatten").
Not RTL-specific — plain LTR text shows it too — but bilingual/RTL documents are hit hardest because they're exactly where font substitution carries missing punctuation (in our Arabic parent reports every {n}% stat and {label}: line tofu'd).
To Reproduce
// repro.mjs — run: node repro.mjs out.pdf (with @react-pdf/[email protected])
import { Document, Page, Text, Font, renderToBuffer } from "@react-pdf/renderer";
import React from "react";
const CDN = "https://cdn.jsdelivr.net/fontsource/fonts";
// Any first font missing "%" shows it; the Arabic subset build is a handy public example.
Font.register({ family: "NoPercent", fonts: [{ src: `${CDN}/noto-sans-arabic@latest/arabic-400-normal.ttf` }] });
Font.register({ family: "Latin", fonts: [{ src: `${CDN}/noto-sans@latest/latin-400-normal.ttf` }] });
const e = React.createElement;
const doc = e(Document, null,
e(Page, { size: "LETTER", style: { fontFamily: ["NoPercent", "Latin"], fontSize: 14, padding: 40 } },
e(Text, null, "single string: 69%"), // renders "69%" ✓
e(Text, null, "two fragments: ", "69", "%") // renders "69□" ✗ on 7.0.1
),
);
renderToBuffer(doc).then(async (buf) => (await import("fs")).writeFileSync(process.argv[2], buf));
With overrides: { "@react-pdf/textkit": "7.0.0" } both lines render 69%. With 7.0.1 (what every current renderer resolves), the fragmented line renders 69□.
Root cause (from the 7.0.0 → 7.0.1 diff)
Two changes interact in lib/textkit.js:
preprocessRunsdroppedomitFontand reordered the concat, so the original runs keep their rawfont(stack) attribute and the substitution runs are concatenated last, relying on flatten's merge order to let the substituted font win:
// 7.0.0
const runs = bidiRuns.concat(substitutedRuns).concat(itemizationRuns).concat(omittedFontRuns);
// 7.0.1
const runs = attributedString.runs.concat(bidiRuns).concat(itemizationRuns).concat(substitutedRuns);
- The rewritten
flattennow emits each segment's attributes asObject.assign({}, ...stack), wherestackis the set of currently-open runs in push order. A run that starts mid-string is pushed at its start offset — i.e. after the substitution run that was pushed at offset 0. So for every fragment run except the first, the original run sits later in the stack than the substitution run, and its rawfontattribute overwrites the substituted font.
Concretely, for fragments ["two fragments: ", "69", "%"]: substitution runs are computed over the whole string and pushed onto the stack at their start offsets; when the original fragment runs "69" and "%" start mid-string, they are pushed after the substitution run covering that position, so those segments get font: [NoPercent, Latin] (the raw stack) instead of the substituted font. 69 happens to survive because the Arabic subset contains Latin digits; % does not exist there and renders .notdef.
7.0.0 was immune regardless of stack order because omitFont stripped font from the original runs before flattening.
Expected behavior
Fragmented and single-string text render identically; the substituted font always wins over the raw stack attribute, as in ≤ 7.0.0.
Desktop
- OS: macOS (Darwin 25.5), Node v24.18.0
- React-pdf version:
@react-pdf/renderer4.9.0 (@react-pdf/textkit7.0.1); regression absent in textkit 7.0.0
- Dominant language
- TypeScript
- Stars
- 16.8k
- Forks
- 1.3k
- Avg merge
- 11m
- Merged PRs (30d)
- 2
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from diegomura/react-pdf
-
@react-pdf/font: published types import fontkit, but @types/fontkit is only a devDependencyPossibly taken @iliev-blnk claimed this 3 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
Unable to set flexShrink to 0Possibly taken @adarshsm claimed this 2 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
Maintainers usually reply within 1 day
-
@react-pdf/hyphenate: import-only exports map throws ERR_PACKAGE_PATH_NOT_EXPORTED in require-condition resolvers (every tsx script loading renderer >= 4.7 crashes)Possibly taken @cruzdanilo claimed this 32 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
diegomura/react-pdf#3551 · 2 reactions ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 48/100
diegomura/react-pdf#3606 · 1 reaction ·
Maintainers usually reply within 1 day
-
Text: first glyph dropped at line/run start when the font inserts glyphs via contextual alternatesOpen
Difficulty 3/5 1-2 days Newbie friendliness 54/100
Maintainers usually reply within 1 day
All issues in diegomura/react-pdf
Similar issues
-
type: bug
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
Maintainers usually reply within 1 day
-
0️⃣ phase/new
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
Difficulty 1/5 Under an hour Newbie friendliness 70/100
microsoft/playwright#43640 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100