nigig-org/REVIEWS/adr/0032-pdf-nested-content.md
andodeki d3089bc62a
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(pdf): nested content, the wire codec and tiled rendering — Phase 7 closed
Three bullets, and the Phase 7 status table rewritten row by row.

**Nested content (ADR 0032).** A form XObject and a Type 3 glyph are the
same problem: a content stream inside a content stream. Both were parsed
completely and then not run. `paint_x_object` reported the name for "the
host" to resolve and no host existed, so `Do` painted nothing. Type 3 was
worse because it looked more correct — `d0`/`d1` reached the device, so the
pen advanced by the declared width and the page rendered an invisible line
of text with correct spacing after it.

`nested.rs` runs both, in pdf-graphics because the dependency runs graphics
→ document and this is the only crate that can see the interpreter and the
object model at once. Forms get their `/Matrix`, their `/BBox` clip and a
save/restore wrapper, because without the wrapper a form's colour leaks
into every object after it and looks like a bug in the document. Type 3
composes translate-then-matrix; the other order scales the translation and
puts the glyph at (1.7, 16.8) instead of (72, 700). Recursion is bounded in
both: unbounded, a self-referencing form is a stack overflow reachable from
an untrusted document, which is a denial of service and not a rendering bug.

**Wire codec and tiling (ADR 0033).** `worker.rs` moved interpretation off
the UI thread only because both ends shared a Vec. Tags are explicit
numbers, never declaration order, so reordering the enum cannot silently
make old recordings decode as different commands. Truncation is an error
rather than a short list — a decoder that stopped early would render a page
missing its last few operations, plausible and wrong.

The obvious truncation test failed, correctly: `Save` is one byte, so a cut
on a command boundary really is a complete list. It now tries every cut
position and requires each to be a named error or a genuine prefix.

Tile skipping is conservative. A command whose geometry is unknown is kept,
because dropping a state change corrupts everything after it in that tile,
silently. Only untransformed geometry that provably falls outside is
dropped. Every tile is asserted pixel-identical to that region of the
whole-page render: tiling that is fast and different is not an
optimisation.

Eight mutations across the two modules, all killed.

Phase 7 status is now two tables — the eight spec bullets and the exit
criteria — with what is missing named in the row rather than rounded up.
Three rows are not green: Makepad blend compositing needs render-to-texture,
the image-XObject pixel golden asserts the request rather than pixels, and
the `ui.rs` smoke tests remain blocked on the headless backend they have
been blocked on since Phase 1.

1425 tests pass, coverage 88.10% (was 87.60%), all floors met, external
readers pass.

ADRs 0032 and 0033.
2026-08-18 20:00:18 +00:00

5.6 KiB
Raw Permalink Blame History

ADR 0032: nested content — form XObjects and Type 3 glyphs were never run

  • Status: Accepted
  • Date: 2026-08-18
  • Review item: NIGIG_PDF_FEATURE_PARITY_PLAN.md §1 Phase 7, "Type 3 font rendering" and "Form/image XObject rendering through the device trait"
  • Related: ADR 0014 (Type 3 parsing), ADR 0028 (sh was parsed and discarded — the same shape)

Context

Two Phase 7 bullets are one problem: a content stream nested inside another content stream. A form XObject is one painted by Do; a Type 3 glyph is one painted by Tj. Both were parsed completely and then not run.

PdfDevice::paint_x_object carried this comment:

The interpreter cannot resolve the name itself, so it reports it and the device performs the lookup.

Correct, and no device ever did. The Makepad renderer pushed the name onto pending_xobjects for a host to drain; the recording device recorded it; the plan's own status table said "partial — the request is recorded, the host resolves it." Nothing was a host. Do painted nothing.

Type 3 was worse, because it looked more correct. ADR 0014 parsed /CharProcs, /Encoding /Differences, /FontMatrix and /Widths, and d0/d1 reached the device — so the pen advanced by the declared width after each glyph. The result was an invisible line of text with correct spacing after it: a page that measures right and shows nothing.

This is ADR 0028's shape for the third time. A blank region is a legal thing for a page to contain, so "drew nothing" and "drew what was asked" are indistinguishable without an assertion that names the expected output.

Decision

One module, because it is one problem

nested.rs renders both. They differ in what wraps the stream — a /Matrix and a /BBox clip for a form, the /FontMatrix and the pen position for a glyph — and not in what runs it.

It lives in pdf-graphics, not pdf-document

Both need the document, to resolve a reference and to read the nested stream's own /Resources. The crate dependency runs graphics → document, so this is the only crate that can see both halves. The original comment was right that the interpreter cannot do it alone; it was wrong only in expecting that somebody else would.

The operators the device sees are the ones a reader would issue

A form is wrapped in q/Q with its /Matrix concatenated and its /BBox installed as a clip (§8.10.2). Not decoration:

  • Without the wrapper, the form's colour and transform leak into the page after it, and every later object is drawn in the form's colour — which looks like a bug in the document.
  • Without /Matrix, the form draws at the wrong size and place, which looks like a layout bug.
  • Without the /BBox clip, a form whose content spills outside its box draws the overspill, which is visible and wrong.

Each has a test, and each of those tests killed a mutation.

Type 3 composition order

translate(pen) then concat(font_matrix × size). The other order scales the translation too and puts the glyph at (1.7, 16.8) instead of (72, 700). The /FontMatrix is applied as written: a Type 3 font may use any matrix — that is the point of the entry — and assuming 1/1000 draws a glyph designed on a unit square a thousand times too small, which renders as nothing and reads as a missing glyph.

Recursion is bounded, and that is a security decision

A form XObject may reference itself. §9.6.5 forbids a Type 3 glyph from doing so and a malformed file does it anyway. Unbounded, either is a stack overflow reachable from an untrusted document — a denial of service, not a rendering bug. MAX_NESTING_DEPTH is 8: past anything real, and bounded work in the worst case.

An image XObject is refused by name

Do on an image is painted by the device's image path, not by interpreting the stream as content. NestedError::NotAForm says so, which is the difference between "we do not draw this" and "we drew nothing".

Consequences

  • Form XObjects and Type 3 text paint.
  • The Makepad renderer's pending_xobjects drain is now satisfiable: a host has render_form to call. Wiring the widget to call it is UI work and is not done here.
  • interpret_ops failures inside a nested stream are reported, not swallowed. A half-drawn form is a fact the caller needs.

Merge criteria

Enumerated from the plan bullets first, per ADR 0021.

Criterion State
A form XObject's content reaches the device a_form_xobject_is_run_rather_than_reported
/Matrix concatenated mutation-killed
/BBox clip applied, before the content mutation-killed
Wrapped in save/restore asserted first and last
Form's own /Resources resolved carried on FormXObject
Image XObjects refused by name NestedError::NotAForm
Unknown resource refused by name
Recursion bounded mutation-killed
A Type 3 glyph procedure is interpreted against type3/basic.pdf
/FontMatrix and size compose correctly 0.024 asserted; mutation-killed
Pen position not scaled by the font matrix asserted in the same test
A custom /FontMatrix is used as written type3/custom_matrix.pdf
A missing glyph procedure is refused by name type3/missing_glyph.pdf
Recursive Type 3 bounded type3/recursive.pdf
Type 3 glyph caching deferred — each glyph is re-interpreted per occurrence
The Makepad widget calls render_form deferred — UI wiring; the renderer still only records the request
Mutation-checked 4 mutations on this module, all killed