Phase 8 #4 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("Advanced colour"). Design and merge criteria in REVIEWS/adr/0006-pdf-advanced-color.md. The interpreter tracked only the *name* of the active colour space and then passed sc/scn operands to the device as if they were already RGBA. Every non-device space therefore rendered a confident wrong colour with no error: /Spot cs 1.0 scn full tint of a spot ink -> pure red /Idx cs 3 scn palette entry 3 -> near-black /Lab cs 50 0 0 scn mid gray -> white (clamped) /DevN cs (5 inks) five colorants -> inks 5+ discarded /ICCBased profile-defined colour -> profile discarded DeviceCMYK also used the additive 1-c-k conversion, which crushes any colour printed over black. Three new modules in pdf-graphics: - function.rs PDF functions, all four types. Type 4 runs on a bounded interpreter: depth 32, 32768 tokens, stack 100, 100000 steps, and an unknown operator is an error rather than a no-op that would leave a plausible wrong colour. - icc.rs ICC matrix/TRC and gray kTRC profiles, applied exactly. LUT-class profiles are reported as such and the caller falls back to /Alternate; they are never pretended to be matrix profiles. - colorspace.rs All eleven families, converting through XYZ with Bradford adaptation and a real sRGB transfer function. Wiring: - PdfDevice gains set_stroke_components/set_fill_components, so SC/SCN reach the device as components of the active space instead of being read positionally as RGBA. - cs/CS now resets to the space's initial colour (table 74), which is why golden/colors.txt gains a line. - PdfPage::color_spaces carries /Resources /ColorSpace fully dereferenced with streams decoded; a half-resolved space would make every ICC profile, palette and type 0/4 transform silently fall back. - A space that cannot be resolved keeps the previous colour and records a typed ColorError. No colour is invented, and no error is swallowed. Tests: 12 corpus fixtures under tests/corpus/color/, 14 acceptance tests in pdf-document/tests/color.rs asserting numeric RGB (the broken code produced a colour for every one of these; only the value was wrong), plus unit tests per function type and per curve type. Two fuzz targets added: eval_function and parse_colorspace. TEST_TARGET=pdf 387 -> 447 passing, TEST_TARGET=pdf-ui 431 -> 491. rustfmt and clippy -D warnings clean.
6.7 KiB
ADR 0006: PDF advanced colour — real colour spaces, real tint transforms
- Status: Accepted
- Date: 2026-07-28
- Review item: Phase 8,
DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md("Advanced colour (ICC, DeviceN, Separation)") - Supersedes: nothing
- Related: ADR 0005 (encryption) — same rule that a wrong-but-plausible result is worse than a refusal
Context
The interpreter tracked the name of the active colour space and nothing
else. set_color_space_fill(name) stored a string; the sc/scn operands
that followed were then passed to the device as if they were already RGBA:
PdfOp::FillColor(colors) => device.set_fill_color([
colors.first().copied().unwrap_or(0.0), // read as red
colors.get(1).copied().unwrap_or(0.0), // read as green
colors.get(2).copied().unwrap_or(0.0), // read as blue
colors.get(3).copied().unwrap_or(1.0), // read as alpha
]);
Every non-device colour space therefore rendered wrong, silently:
| Content | What the file means | What was drawn |
|---|---|---|
/Spot cs 1.0 scn |
full tint of a spot ink | pure red rgb(1,0,0) |
/Idx cs 3 scn |
palette entry 3 | near-black rgb(0.012,0,0) |
/Lab cs 50 0 0 scn |
mid gray | white (clamped) |
/DevN cs with 5 inks |
five colorants | inks 5+ discarded |
/ICCBased |
profile-defined colour | profile discarded |
DeviceCMYK was also converted with the additive 1 - c - k form, which
crushes rich blacks; the correct form is multiplicative.
None of these produced an error. They produced a confident wrong colour, which is the exact defect class the review's rule 6 exists to prevent.
Making any of this right requires a PDF function evaluator, because
/Separation and /DeviceN are defined by their tint transform. There
was none.
Decision
Implement colour properly in pdf-graphics, in three new modules.
function.rs — PDF functions (§7.10)
All four types: 0 (sampled, multilinear), 2 (exponential), 3 (stitching), 4 (PostScript calculator).
Type 4 executes attacker-controlled bytes, so the interpreter is bounded on purpose: the language has no loops, and nesting depth (32), program size (32768 tokens), operand-stack depth (100, the spec's own limit) and executed steps (100000) are all capped. Exceeding any cap is a typed error, not a longer run. An unknown operator is an error, never a no-op — skipping it would leave a plausible wrong colour on the stack.
icc.rs — ICC profiles, limited on purpose
Matrix/TRC RGB profiles and gray kTRC profiles are parsed and applied
exactly: rXYZ/gXYZ/bXYZ colorants, curv (identity, gamma, sampled)
and para (types 0–4) curves.
LUT-based profiles (A2B0, i.e. most CMYK profiles) are reported as
IccKind::LutBased and not applied; the caller falls back to
/Alternate. Pretending a LUT profile is a matrix profile would be
confidently wrong. Implementing full LUT/CLUT interpolation with
perceptual rendering intents is a separate piece of work and is out of scope
here.
colorspace.rs — the spaces themselves
DeviceGray, DeviceRGB, DeviceCMYK, CalGray, CalRGB, Lab,
ICCBased, Indexed, Separation, DeviceN, Pattern. Conversion goes
through XYZ with Bradford chromatic adaptation to D65 and a real sRGB
transfer function, so Lab and the calibrated spaces land where they should
instead of clamping.
/Separation /All darkens with tint; /Separation /None and an all-None
/DeviceN paint nothing (white); both are spec behaviour, not guesses.
Errors are surfaced, not swallowed
ColorError distinguishes an unknown family, an unresolved named resource,
a malformed space, a tint-transform failure and a component-count mismatch.
When a page's colour space cannot be resolved the interpreter keeps the
previous colour and records the error, and the caller can read the list.
It does not substitute black, and it does not substitute magenta: inventing
a colour is what this ADR exists to stop. The recorded error is how a host
tells "this page is monochrome" from "this page's colour failed".
Non-negotiable rules
- No colour is invented. A space that cannot be resolved produces a typed error, not a default.
- No component is dropped.
/DeviceNwith n inks passes all n to the transform. - Type 4 programs are bounded in depth, size, stack and steps, and an unknown operator aborts the evaluation.
- A profile that cannot be applied is declared, and
/Alternateis used explicitly rather than silently. pdf-graphicsgains no UI dependency; colour conversion is pure.
Merge criteria
/Separationrenders through its tint transform, verified against a fixture whose expected RGB is not the raw tint./DeviceNwith five inks keeps the fifth./Indexedreads its palette, and an out-of-range index clamps to/HiValrather than reading past the table./Labmid-gray is mid-gray, not white./ICCBasedwith a matrix/TRC profile applies the profile; with a LUT profile it falls back to/Alternateand says so.DeviceCMYKuses the multiplicative conversion.- All four function types evaluate, with unit tests per type.
- A malformed or hostile type 4 program returns an error inside the budget instead of hanging.
- Corpus fixtures for the above, generated by the checked-in script.
- Colour-space resolution failures are reported to the caller.
TEST_TARGET=pdf ./tools/test-rust-clean.shpasses; rustfmt and clippy-D warningsclean.
All criteria met. TEST_TARGET=pdf went from 387 to 447 tests and
TEST_TARGET=pdf-ui from 431 to 491, all passing. Two fuzz targets were
added alongside — eval_function (the PostScript calculator) and
parse_colorspace — because both consume file bytes directly.
Consequences
Positive. Spot colours, palettes, calibrated and profile-tagged colour all render correctly. The function evaluator is a prerequisite for shadings (type 2/3 gradients) and for soft masks with transfer functions, so the next two colour-adjacent features get cheaper.
Negative. More work per scn. Mitigated because conversion happens at
interpretation time, once per operator, not per pixel; the resolved spaces
are built once per page.
Risk. The likeliest defect is a subtly wrong matrix or white point that looks plausible. Mitigation: tests assert numeric expected values derived from the specifications (0.5^2.2 for a gamma curve, L*=50 → sRGB 0.464), not merely that a colour came back.
Out of scope, deliberately: LUT-based ICC transforms and rendering
intents; overprint and /OPM; shading patterns and tiling patterns (the
Pattern space is recognised and reported, not painted); black-point
compensation; JBIG2/JPX image colour.