diegomura / diegomura/react-pdf
textkit 7.0.1 regression: multi-child <Text> loses font substitution at fragment boundaries ({value}% renders tofu)
- Dominant language
- TypeScript
- Stars
- 16.8k
- Forks
- 1.3k
- Avg merge
- 5h 6m
- Merged PRs (30d)
- 52
Description
**Describe the bug**
Since `@react-pdf/textkit` 7.0.1, a `` 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**
```js
// repro.mjs — run: node repro.mjs out.pdf (with @react-pdf/renderer@4.9.0)
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`:
1. `preprocessRuns` dropped `omitFont` and reordered the concat, so the **original runs keep their raw `font` (stack) attribute** and the substitution runs are concatenated last, relying on flatten's merge order to let the substituted font win:
```js
// 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);
```
2. The rewritten `flatten` now emits each segment's attributes as `Object.assign({}, ...stack)`, where `stack` is 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 raw `font` attribute 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/renderer` 4.9.0 (`@react-pdf/textkit` 7.0.1); regression absent in textkit 7.0.0
Contributor guide
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.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100