# 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: ```rust 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 1. **No colour is invented.** A space that cannot be resolved produces a typed error, not a default. 2. **No component is dropped.** `/DeviceN` with n inks passes all n to the transform. 3. **Type 4 programs are bounded** in depth, size, stack and steps, and an unknown operator aborts the evaluation. 4. **A profile that cannot be applied is declared**, and `/Alternate` is used explicitly rather than silently. 5. **`pdf-graphics` gains no UI dependency**; colour conversion is pure. ## Merge criteria - [x] `/Separation` renders through its tint transform, verified against a fixture whose expected RGB is not the raw tint. - [x] `/DeviceN` with five inks keeps the fifth. - [x] `/Indexed` reads its palette, and an out-of-range index clamps to `/HiVal` rather than reading past the table. - [x] `/Lab` mid-gray is mid-gray, not white. - [x] `/ICCBased` with a matrix/TRC profile applies the profile; with a LUT profile it falls back to `/Alternate` and says so. - [x] `DeviceCMYK` uses the multiplicative conversion. - [x] All four function types evaluate, with unit tests per type. - [x] A malformed or hostile type 4 program returns an error inside the budget instead of hanging. - [x] Corpus fixtures for the above, generated by the checked-in script. - [x] Colour-space resolution failures are reported to the caller. - [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes; rustfmt and clippy `-D warnings` clean. 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.