`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.
147 lines
7.6 KiB
Markdown
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 |
|