# 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: ```rust 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_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 - [x] `sh` reaches the device instead of being discarded - [x] Axial (type 2) shadings, exact - [x] Radial (type 3) shadings, exact - [x] Function-based (type 1) shadings, with matrix inversion - [x] Free-form (4) and lattice (5) meshes as triangles - [x] Coons (6) and tensor (7) patches, corner-flattened and **declared approximate** - [x] Unsupported types refused by number - [x] `/Extend` honoured per end - [x] Outside an unextended shading is `None`, not black - [x] Corpus fixtures for axial, radial and an unsupported type - [x] 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 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.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 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.