`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.
7.6 KiB
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:
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 |