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