# 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 |