Skip to content

textkit 7.0.1 regression: multi-child <Text> loses font substitution at fragment boundaries ({value}% renders tofu) #3552

Description

@spritezap

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:

  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:
// 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);
  1. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions