Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

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

Open
#3,552 0 comments 0 reactions 0 assignees View on GitHub

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:

  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
Dominant language
TypeScript
Stars
16.8k
Forks
1.3k
Avg merge
11m
Merged PRs (30d)
2

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from diegomura/react-pdf

All issues in diegomura/react-pdf

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.