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.
132 lines
6.4 KiB
Markdown
132 lines
6.4 KiB
Markdown
# 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'
|
|
`SetTransform`s 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 |
|