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.
7.4 KiB
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 1–3 exact, types 4–7 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_bluechecks 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_axisis 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_symmetricsamples 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
shreaches 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
/Extendhonoured 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.rssits at 68% line coverage and the uncovered part isparse_meshandtriangulate. They are unit-tested only throughBitReaderanddecode_value; no real type 4–7 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.rsasserts 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 4–7) 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.