nigig-org/REVIEWS/adr/0033-pdf-wire-codec-and-tiling.md
andodeki 1740da3f34
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): render a form XObject to pixels — the golden caught what the
assertions missed

`Rasteriser::register_xobject` takes a form's recorded commands from a
caller that can resolve the page dictionary, so Phase 7's last golden-corpus
criterion is met with pixels instead of with a request recorded by name.

The first golden of that page showed the form drawn at the **page origin**,
ignoring the `1 0 0 1 20 20 cm` that placed it. The recorded commands'
`SetTransform`s are absolute in form space, and replaying them overwrote the
page's CTM rather than composing with it. Nested lists now compose against
the CTM in force at the `Do`.

The colour assertions written next to that golden all passed while the bug
was live — a red square two pixels from where it belongs is still a red
square somewhere. That is the argument for pixel goldens in one sentence,
and it is why the golden is compared after the assertions and not instead
of them. The offset now has its own assertion too.

The new fixture's form deliberately overflows its own /BBox, so the clip is
visible in the golden as an absence rather than being taken on trust.

Phase 7's golden-corpus exit criterion is now met in full. The `ui.rs` smoke
tests remain blocked on the Makepad headless backend, as they have been
since Phase 1, and are still not claimed as done.

1426 tests pass.
2026-08-18 20:06:50 +00:00

6.4 KiB

ADR 0033: a wire codec for RenderCommand, and a tiled render sink

  • Status: Accepted
  • Date: 2026-08-18
  • Review item: NIGIG_PDF_FEATURE_PARITY_PLAN.md §1 Phase 7, "RenderCommand wire codec: binary serialize/deserialize … and tiled rendering sink (PdfTiledCellSink equivalent)"
  • Related: ADR 0030 (the rasteriser this replays into), ADR 0011 (the render worker)

Context

worker.rs already moves interpretation off the UI thread — but only because both ends are in one process and share a Vec<RenderCommand>. A separate process, a WASM worker, or a disk cache all need the command list to be bytes, and there was no way to make it one.

Tiling had the same gap: a large page at high zoom does not fit in one buffer, and the obvious implementation — re-interpret the content stream per tile — is quadratic in the tile count for no benefit, since interpretation does not depend on the tile.

Decision

Explicit tags, never declaration order

Every command has a numbered tag written down in a tag module. Deriving them from enum order would mean that reordering RenderCommand silently changes the format, and an old recording would decode as different commands — a page that renders plausibly and wrongly.

Fail loudly, never short

Every variable-length field carries its length, and a length past the end of the buffer is LengthOutOfRange, not a truncated read. An unknown tag is refused rather than skipped, because without knowing the tag the payload length is unknown and everything after it would decode as garbage.

The test for this is stronger than the one first written. The obvious version — cut the buffer at a few points, expect an error — failed, and correctly: Save and Restore are one byte each, so a cut on a command boundary really is a complete list. The test now tries every cut and requires each to be either a named error or a genuine prefix of the full list. That distinction is the whole point: a decoder that silently stopped early would render a page missing its last few operations.

Float bit patterns, not decimals

A text encoding loses the last bits, and the round-trip test would then have to compare approximately — a test that cannot see small drift. Exactness is asserted by re-encoding: a correctly decoded list reproduces the buffer bit for bit.

What does not survive, said out loud

A SoftMask holds a resolved /G group object and a boxed transfer function. Neither can be serialised without serialising the object model. The kind and the backdrop cross the wire; the group and the transfer do not, and the decoder returns them as None. This is stated in the encoder, in this ADR, and in the merge table — because a decoder that produced a mask with no group and said nothing would look like a working round trip.

Tile skipping is conservative

A command whose geometry is unknown is kept. Dropping a state change — a colour, a transform, a clip — corrupts every command after it in that tile, and the corruption is silent. Only a rectangle that provably falls outside is dropped, and only when no transform is in effect, because a transformed rectangle's device bounds are not its operands.

Two mutations test exactly this: dropping every rectangle, and dropping every state command. Both are killed.

Correctness before speed

a_tile_renders_the_same_pixels_as_the_whole_page compares every pixel of every tile against the whole-page render. Tiling that is fast and different is not an optimisation.

Consequences

  • A command list can cross a process, thread or storage boundary.
  • Tiles render identically to the whole page, and a tile that touches nothing still receives every state command.
  • The format is not versioned. It is an internal boundary between two halves of one build, not a file format, and adding a version header would imply a compatibility promise this does not make. If it is ever persisted across releases, that promise has to be made first.
  • Soft masks lose their group across the wire. A consumer that needs the group must resolve it from the document, as it must anyway.

Addendum: the golden that caught what the assertions did not

Rasteriser::register_xobject lets a caller hand over a form's recorded commands, so the image-XObject exit criterion can be met with pixels rather than with a request recorded by name.

The first golden of that page showed the form drawn at the page origin, ignoring the 1 0 0 1 20 20 cm that placed it: the recorded commands' SetTransforms are absolute in form space, and replaying them overwrote the page's CTM instead of composing with it. Nested lists now compose against the CTM in force at the Do.

The colour assertions written alongside that golden all passed while the bug was live — a red square two pixels from where it belongs is still a red square somewhere. This is the argument for pixel goldens in one sentence, and it is why the golden is compared after the assertions rather than instead of them.

Merge criteria

Criterion State
Binary encode/decode of RenderCommand
Every variant round-trips and a tag-coverage test fails if a variant has no sample
Floats survive exactly asserted by re-encoding, not by approximate comparison
Truncation is an error, never a short list every cut position tried; errors or genuine prefixes only
Unknown tag refused
Out-of-range length refused before allocating
Bad enum discriminant refused
Invalid UTF-8 refused
Soft-mask group crosses the wire by design — stated in the encoder and here; kind and backdrop survive
Format versioning deliberately not done — an internal boundary, not a file format
Tiles cover the page exactly once including a partial last tile
A tile is pixel-identical to that region of the whole page
Off-tile geometry skipped mutation-killed
State changes never skipped mutation-killed
Transformed geometry never skipped
Record → encode → decode → replay is pixel-identical the worker-thread boundary end to end
A form XObject renders to pixels through the rasteriser golden_form_xobject_page
A nested list composes with the CTM at the Do mutation-killed; found by the golden
Mutation-checked 5 mutations on this module, all killed