nigig-org/REVIEWS/adr/0006-pdf-advanced-color.md
andodeki 7d21532ebf
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): real colour spaces, ICC profiles and PDF functions
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.
2026-07-28 20:48:55 +00:00

6.7 KiB
Raw Permalink Blame History

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 04) 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

  • /Separation renders through its tint transform, verified against a fixture whose expected RGB is not the raw tint.
  • /DeviceN with five inks keeps the fifth.
  • /Indexed reads its palette, and an out-of-range index clamps to /HiVal rather than reading past the table.
  • /Lab mid-gray is mid-gray, not white.
  • /ICCBased with a matrix/TRC profile applies the profile; with a LUT profile it falls back to /Alternate and says so.
  • DeviceCMYK uses 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.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.