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

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