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.
122 lines
5.6 KiB
Markdown
122 lines
5.6 KiB
Markdown
# 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 |
|