nigig-org/REVIEWS/adr/0030-pdf-compositing-and-overprint.md
andodeki 0bef30a6d5
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(pdf): compositing and overprint — the blend maths had no backdrop
`transparency.rs` implemented all sixteen blend modes and unit-tested them
against the specification's formulas. Nothing ever called them with a
backdrop. The Makepad renderer's `SetBlendMode` pushed a
`TransparencyError::Unsupported` and then painted the source colour, so a
/Multiply highlight and a /Normal one produced byte-identical output and
every test passed — because every test asked "was the right command
issued", not "does the page look right".

Overprint had no code at all. /OP, /op and /OPM were not parsed, so an
overprinting object knocked out the inks under it. That is not a missing
feature, it is the inverse of the instruction: on a press it is the
difference between a colour and a hole.

- `composite.rs`: a straight-alpha RGBA `Canvas` implementing §11.3.6's
  union formula, weighted by backdrop alpha so a Multiply over transparency
  is the source rather than black. Constant alpha and per-pixel soft masks.
  Transparency groups composite as a unit; knockout groups are refused by
  name rather than silently treated as non-knockout.
- Overprint as `composite_cmyk`, separate from the RGB path rather than a
  flag on it: overprint is a statement about inks and RGB has none. /op
  defaults to /OP per table 58 — defaulting it to false makes the common
  `<< /OP true >>` knock out every fill. §10.7.5's "no effect on an RGB
  device" is asserted, so our doing nothing there is the spec rather than
  an omission.
- `raster.rs`: a CPU rasteriser that replays a command list onto a canvas.
  Not on the display path, no anti-aliasing, no fonts; it exists so
  compositing has a verifiable output. In pdf-graphics and not pdf-makepad
  because a test that needs a GPU is a test that does not run.
- Golden **pixels** for shading, mesh, blend and overprint pages — Phase
  7's exit criterion, which the Phase 2 command-text goldens cannot meet.
  ASCII grids with a colour legend, quantised to quarter steps; each test
  asserts its exact colours before comparing, so a wrong-but-stable render
  cannot be blessed by an UPDATE_GOLDEN run.

Six mutations, all killed, including the two that describe the old
behaviour: discarding the blend result, and ignoring the overprint flag.

1364 tests pass. ADR 0030.
2026-08-18 19:25:15 +00:00

147 lines
7.6 KiB
Markdown

# ADR 0030: compositing and overprint — the blend maths had no backdrop
- **Status:** Accepted
- **Date:** 2026-08-18
- **Review item:** `NIGIG_PDF_FEATURE_PARITY_PLAN.md` §1 Phase 7, "Blend
modes + overprint compositor", "Transparency groups … extend to
blend-mode compositing", and the exit criterion "golden render corpus
covers shading/mesh/overprint/image-XObject pages"
- **Supersedes:** the scope boundary declared in `transparency.rs`
- **Related:** ADR 0009 (transparency), ADR 0028/0029 (shadings)
## Context
`transparency.rs` opened with an honest limit:
> this module computes blend functions and resolves parameters. It does
> *not* perform backdrop compositing — that needs a framebuffer, which an
> engine-neutral crate does not have.
That was right for Phase 3 and wrong to leave standing. `BlendMode::blend`
was implemented for all sixteen modes and unit-tested against the
specification's formulas — and **nothing ever called it with a backdrop**.
The Makepad renderer's handling of `SetBlendMode` was:
```rust
RenderCommand::SetBlendMode(mode) => {
if *mode != BlendMode::Normal {
self.unsupported.push(TransparencyError::Unsupported(...));
}
}
```
It recorded a complaint and then painted the source colour. So a
`/Multiply` highlight and a `/Normal` one produced byte-identical output,
and every test in the suite passed, because every test in the suite asked
"was the right command issued" rather than "does the page look right".
**Overprint had no code at all.** `/OP`, `/op` and `/OPM` were not parsed,
so an overprinting object knocked out the inks underneath it. That is not a
missing feature; it is the *opposite* of the instruction. On a press it is
the difference between a colour and a hole.
## Decision
### Own a framebuffer, in `pdf-graphics`
`composite.rs` carries a `Canvas` of straight-alpha RGBA pixels and
implements §11.3.6's union formula. A framebuffer is not a graphics engine
— it is an array of pixels — and the reason to own one is that it makes the
blend maths *falsifiable*. Six of the mutations run against this module were
killed only by tests that composite; none of the pre-existing `blend` tests
noticed them.
It lives in `pdf-graphics` and not `pdf-makepad` because a test that needs a
GPU is a test that does not run: the Makepad UI suite has been blocked on a
headless backend since Phase 1, and putting compositing behind that block
would have meant shipping it unverified.
### Blend weighted by backdrop alpha, not applied flat
§11.3.6 weights the blended colour by how much backdrop there is, so a
Multiply over a *transparent* backdrop is the source unchanged. Applying the
blend flat gives black there. Both spellings pass a naive "Multiply darkens"
test; only `a_blend_over_a_transparent_backdrop_is_the_source` separates
them, and the mutation that drops the weighting is killed by it alone.
### Overprint is CMYK-only, and says so
`composite_cmyk` is a separate function rather than a flag on
`composite_pixel`. Overprint is a statement about *inks*, and RGB has none —
§10.7.5 says it has no effect on an RGB device. Making it a flag would let a
caller pass an RGB pixel and an overprint state and receive a plausible
wrong answer. `ProcessColorants` makes the device's ink model explicit, and
`overprint_has_no_effect_on_an_rgb_device` records that our doing nothing
there is the spec's instruction rather than our omission.
`/op` defaults to `/OP` (table 58). Defaulting it to `false` — the obvious
reading — makes the common `<< /OP true >>` a stroking-only instruction and
quietly knocks out every fill; that mutation is killed by
`op_defaults_the_fill_flag_to_the_stroke_flag`.
`ExtGState::overprint` is `Option`, like every other field: `None` means the
ExtGState named no overprint entry and the existing state survives. A
concrete default would make every `gs` operator reset overprint.
### A CPU rasteriser, and golden **pixels**
`raster.rs` replays a `RenderCommand` list onto a `Canvas`. It is not on the
display path and is not a production renderer: no anti-aliasing, no join
geometry, no font rasterisation, and it names what it cannot resolve rather
than pretending. It exists so Phase 7's exit criterion — "golden render
corpus covers shading/mesh/overprint/image-XObject pages" — can be met with
pixels rather than with command text.
The goldens are ASCII grids with a colour legend, quantised to quarter
steps. Quantised because a gradient has one distinct colour per pixel and an
exact legend would be longer than the picture; **and** because the precision
is not what the golden is for — each test asserts its exact colours in the
test body *before* comparing the golden, so a wrong-but-stable render cannot
be blessed by an `UPDATE_GOLDEN=1` run. Text and not PNG because a golden
nobody can read in a diff is a golden nobody reviews, and three fixtures in
this project have already encoded the bug they were meant to catch.
### What is refused rather than approximated
Knockout transparency groups return `TransparencyError::Unsupported`.
`composite_group` distinguishes isolated from non-isolated, which is the
difference between a Multiply group darkening its backdrop once or twice.
## Consequences
- Blend modes, constant alpha and soft masks produce visibly different
pixels, and the difference is asserted.
- Overprint is parsed and honoured per ink. Documents that rely on it no
longer get the inverse of what they asked for.
- The Makepad renderer still does not composite — it has no
render-to-texture — and still reports so. This ADR does not change that;
it makes the correct behaviour exist and be verified somewhere the tests
can reach. Wiring the Makepad device to a texture target is separate work
and is recorded as such in the Phase 7 table.
- The rasteriser's clip intersection keeps the newest clip rather than
intersecting properly. Correct for the nested rectangles real content
streams use; recorded here as the approximation it is.
## Merge criteria
Enumerated from the plan bullets first, per ADR 0021.
| Criterion | State |
|---|---|
| Blend modes composite against a backdrop | ✅ `composite_pixel`, all 16 modes reachable; Multiply/Screen/Difference asserted numerically |
| Blend is weighted by backdrop alpha (§11.3.6) | ✅ `a_blend_over_a_transparent_backdrop_is_the_source` |
| Constant alpha `ca`/`CA` applied | ✅ `constant_alpha_scales_the_source_contribution` |
| Soft-mask value applied per pixel | ✅ `a_gradient_soft_mask_is_sampled_per_pixel` |
| `/OP`, `/op`, `/OPM` parsed | ✅ `Overprint::from_ext_gstate`, reaching `ExtGState` |
| Overprint honoured per ink | ✅ `overprint_leaves_the_inks_it_does_not_paint` |
| `/op` defaults to `/OP` | ✅ mutation-killed |
| Overprint is a no-op on RGB (§10.7.5) | ✅ asserted, not merely absent |
| Overprint reaches a real page's graphics state | ✅ `transparency/overprint.pdf`, `overprint_entries_are_parsed_from_a_real_page` |
| Transparency groups composite as a unit | ✅ `compositing_a_group_blends_it_as_a_unit` |
| Knockout groups | ❌ **deferred** — refused by name, not silently treated as non-knockout |
| Golden render corpus: shading page | ✅ `shading_axial`, `shading_radial` |
| Golden render corpus: mesh page | ✅ `shading_mesh` |
| Golden render corpus: overprint page | ✅ `overprint_on` / `overprint_off`, as CMYK plates |
| Golden render corpus: image-XObject page | ⚠️ **partial** — the `Do` request is asserted by name; the rasteriser does not resolve page resources, so no pixels |
| Blend compositing in the **Makepad** device | ❌ **deferred** — needs render-to-texture; the renderer still reports `Unsupported` |
| Mutation-checked | ✅ 6 mutations, all killed |