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/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:
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:
// 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
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
Describe the bug
Since
@react-pdf/textkit7.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 rawfontFamilystack'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
With
overrides: { "@react-pdf/textkit": "7.0.0" }both lines render69%. With 7.0.1 (what every current renderer resolves), the fragmented line renders69□.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: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 getfont: [NoPercent, Latin](the raw stack) instead of the substituted font.69happens 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
omitFontstrippedfontfrom 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
@react-pdf/renderer4.9.0 (@react-pdf/textkit7.0.1); regression absent in textkit 7.0.0