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.
5.6 KiB
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 (
shwas 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
/BBoxclip, 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_xobjectsdrain is now satisfiable: a host hasrender_formto call. Wiring the widget to call it is UI work and is not done here. interpret_opsfailures 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 |