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

122 lines
5.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |