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

172 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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