nigig-org/REVIEWS/adr/0028-pdf-shading.md
andodeki c1d1e67f3a
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): shadings — the sh operator was parsed and thrown away
ADR 0028, the first of Phase 7's eight bullets.

content.rs contained `PdfOp::Shading(_name) => {}`. The operator was lexed,
given its own variant, matched during interpretation, and discarded. A page
whose background is a gradient rendered as nothing.

Nothing caught it for the usual reason: a blank region is a legal thing for
a page to contain, so "drew nothing" and "drew what was asked" are
indistinguishable without an assertion naming the expected colour. The
golden corpus had no shading page, so there was nothing to be wrong.

Two of the three pieces already existed — function.rs evaluates the colour
function and colorspace.rs converts it to RGB. What was missing was the
geometry between them.

Sampling rather than a gradient primitive: a PDF shading is defined by an
arbitrary function, possibly a sampled table or a PostScript program, and
neither reduces to a stop list without loss. A device with a native
gradient can still recognise the two-stop case from the samples.

"No colour here" is None, not black. Black is a colour a shading can
legitimately produce, so returning it for "outside an unextended shading"
would paint a rectangle the author never asked for and the caller could not
tell the two apart.

Types 1-5 exact. Coons and tensor patches are flattened to their corners,
which loses the curvature, and is_approximate says so rather than leaving a
caller to assume fidelity. An unknown type is refused by number: a mesh
drawn as a flat fill is a plausible-looking wrong answer.

paint_shading is a new trait method, so the compiler found every
implementor. The Makepad renderer records the request in pending_shadings,
mirroring pending_xobjects — it cannot resolve a /Shading resource because
it does not own the page dictionary, and recording the request is what
stops the operator vanishing a second time. That holds even for types we
refuse, so a host can warn the user.

Four mutations, all killed. The first — discarding sh again — fails three
tests.

Stated plainly and left unticked: the mesh path is written but NOT
exercised by any real stream. shading.rs is at 68% and the uncovered part
is exactly parse_mesh and triangulate. Mesh support should be treated as
unproven, not working: the code runs and produces triangles, and nothing
yet demonstrates they are the right triangles. That is the position
image.rs was in before ADR 0016 found the JPEG decoder was a stub.

The Phase 7 status line is a table from the start this time — one row per
spec bullet, seven of them saying "not started". Per ADR 0021, written
before the work rather than after it.

pdf: 1321 passed (was 1291). pdf-ui: 1366. Coverage 87.60%, floors met.
2026-08-18 17:30:39 +00:00

7.4 KiB
Raw Permalink Blame History

ADR 0028: shadings — the sh operator was parsed and thrown away

  • Status: Accepted
  • Date: 2026-08-18
  • Review item: NIGIG_PDF_FEATURE_PARITY_PLAN.md §1 Phase 7, "Shading: axial/radial/function shading + mesh (free-form/lattice/Coons)"
  • Supersedes: nothing
  • Related: ADR 0017 (declared-versus-delivered — this is the same pattern), ADR 0021 (enumerate criteria from the plan, not from the work)

Context

content.rs contained this:

PdfOp::Shading(_name) => {}

The sh operator was lexed, given its own PdfOp variant, matched during interpretation, and discarded. A page whose background is a gradient rendered as nothing at all.

Nothing caught it, for the reason this project keeps rediscovering: a blank region is a legal thing for a page to contain, so "drew nothing" and "drew what was asked" are indistinguishable without an assertion that names the expected colour. The golden render corpus had no shading page, so there was nothing to be wrong.

Two of the three pieces already existed. function.rs evaluates the colour function — sampled, exponential, stitching and PostScript — and colorspace.rs converts its output to RGB. What was missing was the geometry between them: which function input corresponds to which point on the page.

Decision

Sample, rather than emit a gradient primitive

A device could be handed "axial gradient from A to B with these stops", which is what a GPU wants. That is rejected because a PDF shading is defined by an arbitrary function — possibly a sampled table, possibly a PostScript calculator program — and neither reduces to a stop list without loss.

Shading::color_at_point and sample_grid produce pixels. A device with a native gradient can still recognise the common two-stop case; a device without one gets correct output rather than an approximation.

"No colour here" is not black

color_at_point returns Option<[f64; 3]>. Outside an unextended shading the answer is None, not a colour.

This matters because black is a colour a shading can legitimately produce. Returning black for "outside" would paint a rectangle the author never asked for, and the caller could not tell the two apart — without_extend_a_point_past_the_end_has_no_colour and an_unextended_shading_leaves_holes_outside_its_axis are the tests.

Types 13 exact, types 47 approximate and said so

Type Support
1 function-based exact, with matrix inversion
2 axial exact
3 radial exact, via the spec's quadratic
4 free-form mesh triangles, exact
5 lattice mesh triangles, exact
6 Coons patch corners only — curvature lost
7 tensor patch corners only — curvature lost
anything else ShadingError::Unsupported(n), refused by number

Coons and tensor patches are curved quadrilaterals flattened to two triangles across their corners. That is an approximation, and ShadingGeometry::Mesh::is_approximate reports it rather than leaving a caller to assume fidelity. An unknown type is refused by number: a mesh drawn as a flat fill is a plausible-looking wrong answer, which is worse than a blank region a caller can detect.

The operator reaches the device even when the shading cannot be drawn

PdfDevice::paint_shading is a new trait method, so the compiler found every implementor. The Makepad renderer records the request in pending_shadings, mirroring the existing pending_xobjects pattern: the renderer cannot resolve a /Shading resource because it does not own the page dictionary, and recording the request is what stops the operator vanishing a second time.

the_unsupported_shading_still_reaches_the_device asserts this holds even for a type we refuse — a host that wants to warn the user needs to know a shading was asked for.

Verification

20 unit tests in shading.rs, 10 integration tests in shading_render.rs driven from three new corpus fixtures.

Assertions are on values, because "some colour came out" is what a broken gradient also produces:

  • an_axial_shading_from_a_real_page_ramps_red_to_blue checks red at the left, blue at the right, and that the midpoint is genuinely between them rather than one end repeated.
  • an_axial_shading_is_constant_perpendicular_to_its_axis is the defining property of an axial gradient, and would catch a projection that used distance instead of the dot product.
  • a_radial_shading_from_a_real_page_is_symmetric samples four points at one radius and requires them to agree.

Mutation testing, four mutations, all killed:

Mutation Tests failed
sh discarded again 3
/Extend ignored (always clamp) 2
axial projection ignores the axis direction 1
unsupported type silently accepted as axial 1

The first is the regression itself, and it fails three tests.

Suite: TEST_TARGET=pdf 1321 passed, 0 failed (was 1291). Coverage 87.60% overall, floors met.

Merge criteria

  • sh reaches the device instead of being discarded
  • Axial (type 2) shadings, exact
  • Radial (type 3) shadings, exact
  • Function-based (type 1) shadings, with matrix inversion
  • Free-form (4) and lattice (5) meshes as triangles
  • Coons (6) and tensor (7) patches, corner-flattened and declared approximate
  • Unsupported types refused by number
  • /Extend honoured per end
  • Outside an unextended shading is None, not black
  • Corpus fixtures for axial, radial and an unsupported type
  • Four mutations, all killed
  • Mesh shadings have no corpus fixture. shading.rs sits at 68% line coverage and the uncovered part is parse_mesh and triangulate. They are unit-tested only through BitReader and decode_value; no real type 47 stream is parsed anywhere. This is the honest gap and it is why this criterion is unticked rather than the phase being called done.
  • Golden render page for a shading. The Phase 7 exit criterion asks for one; shading_render.rs asserts colours directly instead, which is stronger per-pixel but does not exercise the golden harness.

Consequences

Positive. Gradient pages render. Between axial and radial that is the overwhelming majority of shadings in real documents — meshes are rare outside generated artwork.

Negative. Sampling is per-pixel function evaluation, which for a PostScript calculator function is genuinely slow. sample_grid gives a caller control over resolution so a thumbnail can sample coarsely, but there is no caching and a full-page shading re-evaluates on every render.

Risk, stated plainly. The mesh path (types 47) is written but not exercised by any real stream. BitReader and decode_value are unit tested, and the triangulation is not. Until a fixture exists, mesh support should be treated as unproven rather than working — the code will run and produce triangles, and nothing yet demonstrates they are the right triangles. That is exactly the position image.rs was in before ADR 0016 found the JPEG decoder was a stub, and it is recorded here so it is not discovered the same way.

Not done in this ADR: the remaining Phase 7 bullets — blend-mode compositing, overprint, CFF glyph outlines, glyph-aware text runs, Type 3 rendering through the device, and the RenderCommand wire codec. This ADR covers the first bullet only, and the plan's Phase 7 status line should say so rather than being written after the fact.