Skip to content

fix(pdf-codec): hold the text state parameters in the graphics state - #861

Merged
Mearman merged 1 commit into
mainfrom
fix/854-855-text-state-in-graphics-state
Sep 3, 2026
Merged

fix(pdf-codec): hold the text state parameters in the graphics state#861
Mearman merged 1 commit into
mainfrom
fix/854-855-text-state-in-graphics-state

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

What this changes

packages/pdf-codec: interpret.ts's GraphicsState now carries the text state parameters -- the font and size a Tf selects, plus Tc/Tw/Tz/TL/Ts -- instead of keeping them in a separate TextState record alongside the text matrix. What is left beside the text matrix is a TextObjectState holding Tm/Tlm and nothing else.

Why

ISO 32000-1 Table 52 lists those parameters among the device-independent graphics state parameters, so q saves them, Q restores them, and a form XObject invoked by Do inherits them (8.10.2). The old split modelled none of that: gsStack held only CTM/fill/stroke/line-width, and ts was a fresh defaultTextState() per runContentStream call that q/Q never touched. Two wrong behaviours followed, one at each boundary.

BT /F1 12 Tf (First) Tj ET q /F2 24 Tf Q BT 0 -20 Td (Second) Tj ET drew the second run in F2 at 24pt. That is not only a mislabelled item: read.ts's convertText decodes a run's glyph codes through whichever font its fontResourceName names, so a leaked name runs the codes through the wrong font's cmap and silently corrupts the extracted Unicode.

A form XObject whose own content omits a redundant Tf because it relies on the caller's already-selected font produced zero text items -- showTextArray and advanceThroughString both early-return on an undefined fontResourceName, and the form's freshly-defaulted state never had one. Same silent-drop failure shape as #851, at the Do boundary rather than the BT one, and exactly what a repeated header/footer form would hit.

Why merged rather than a second parallel stack

#859 floated both options. Merging is what the spec's own division actually says, and it makes both fixes structural rather than two things each operator has to remember:

  • case "q"/case "Q" are unchanged. One push covers everything saved, because everything saved lives in one record.
  • handleDo's formState ({ ...gs, ctm: ... }) is unchanged. Inbound inheritance falls out of the spread that was already carrying the CTM, with no new runContentStream parameter and no seeding argument to keep in sync.
  • The non-leak direction stays correct for the reason it already was: the recursed call binds its own gs local, so a form's own Tf/Tc cannot escape back to the caller.
  • BT becomes text = defaultTextObjectState(). fix(pdf-codec): keep the selected font across a BT with no Tf of its own #856 had to reset exactly two of nine fields by hand and comment why; now the reset object is the two fields, so the same correctness is enforced by the type rather than by care.

A parallel stack would have needed Q to restore seven fields while deliberately skipping two, in lockstep with gsStack -- the kind of hand-maintained pairing that goes wrong later.

The text operators now update gs by spread (gs = { ...gs, charSpace: ... }), matching how w, g, rg, and cm already work in the same switch.

Tests

Nine new cases, each written against the issues' own reproduction content streams and confirmed failing first (7 red, for the stated reasons, before the change):

Full pdf-codec unit suite (1242), workerd suite, lint, and typecheck all pass, as do every dependent package's suites (documents.js 2617, document-cli 331, document-mcp) via turbo run _test --filter=...pdf-codec.

Fixes #854
Fixes #855

#859 is a duplicate of #854 -- same gap, less detail -- and is closed by this PR too.

The font and size a Tf selects, along with Tc/Tw/Tz/TL/Ts, are graphics state parameters
(ISO 32000-1 Table 52), but they lived in a separate TextState record the q/Q stack never
touched and every runContentStream call initialised fresh.

Two distinct wrong behaviours followed.
A Tf inside a q...Q pair kept its font past the matching Q instead of being restored,
so a later text object with no Tf of its own drew in the leaked font --
and read.ts decodes a run's glyph codes through whichever font its fontResourceName names,
so a leaked name corrupts the extracted Unicode rather than merely mislabelling it.
A form XObject invoked by Do started with no font selected at all,
so a form relying on the caller's already-selected font -- legal per 8.10.2,
which runs a form in the graphics state effective at the Do --
produced zero text items, since showTextArray and advanceThroughString both early-return
on an undefined fontResourceName.

GraphicsState now carries those parameters,
leaving the text matrix and text line matrix in a TextObjectState the q/Q stack excludes:
those are text object state (9.4.1), reset by BT and not saved by q.
The existing gsStack push and handleDo's existing formState spread then cover the text state
with no second stack and no new parameter,
and the recursed call's own gs local keeps a form's own text-state changes
from escaping back into the caller.
@Mearman
Mearman marked this pull request as ready for review September 3, 2026 08:08
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T08:12:00.480298Z 492207d Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman merged commit 7efccd7 into main Sep 3, 2026
16 checks passed
@Mearman
Mearman deleted the fix/854-855-text-state-in-graphics-state branch September 3, 2026 08:12
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

1 participant