Files
photocraft/crates/cms/README.md
T
92262d5950 fix(cms): confirm ICC profile identity by bytes, not the 64-bit hash alone (#8863)
Profile::content_hash is a fixed-key 64-bit SipHash of the ICC bytes, so a
crafted profile can share another profile's hash (about 2^32 work). Two
places took an equal hash as proof of an identical profile:

- transform::cached keyed its process-wide cache on the two hashes and the
  options, so a colliding profile was handed the other profile's cached
  transform and converted with the wrong colours.
- Profile::same_colors returned true on equal hashes, so a colliding
  profile was taken for the other one and a needed conversion was skipped.

Add Profile::same_bytes, which compares the ICC bytes (an Arc pointer check
first, then the hash, then the bytes). same_colors uses it instead of the
hash. Each transform cache entry now keeps the ICC bytes of its two
profiles; a hit counts only when they match, and a colliding pair gets its
own transform without replacing the cached entry. The transform is built
after the cache lock is released, as before.

The regression tests give Display P3 the content hash of sRGB (a stand-in
for a found collision): before the fix, same_colors called them the same
and the cache returned sRGB's transform for P3; now both see P3's colours.

Fixes #8613

Co-authored-by: John Turnbach <jturnbach@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:54:42 -04:00

5.4 KiB
Raw Blame History

photocraft-cms

Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable), no C, no unsafe. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper.

API

use photocraft_cms::{Builtin, Intent, Profile, Transform};

let src = Profile::parse(&icc_bytes)?;                    // v2/v4: matrix/TRC, gray TRC, mft1/mft2/mAB/mBA
let dst = Builtin::CoatedCmyk.profile();
let t = Transform::new(&src, dst, Intent::RelativeColorimetric, /*bpc*/ true)?;
t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true);   // also convert_u16 / convert_f32 / apply (in place)
let mut out = [0.0; 4];
t.eval(&[1.0, 0.0, 0.0], &mut out);                        // exact single colour
  • Transform::proof(src, proof, display, intent, bpc, simulate_paper): soft-proof chain.
  • GamutCheck::new(src, proof, ΔE): out-of-gamut test (gamut warning).
  • Lut3d::from_transform(&t, 33).to_rgba16f_bytes(): display transform as a 3D texture.
  • Transform::gpu_matrix_shaper(): accurate RGB input curves + matrix + output curves for display shaders. Float tables retain near-black detail; unsupported chains return None for the 3D LUT fallback. No GPU dependency is required by this crate.
  • transform::cached(src, dst, opts): process-wide transform cache.
  • Profile::to_bytes(): original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
  • Profile::same_colors(&other): the same colour space whatever the encoding (every sample of a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in sRGB. same_bytes(&other) compares the ICC bytes. content_hash() is a 64-bit hash of them that another profile can share: a cache key, confirmed with same_bytes.

Evaluation

  • Exact path: a float stage pipeline (curves, matrices, CLUTs, Lab↔XYZ, PCS encodings, BPC / absolute scaling). Float buffers use it by default (TransformOptions::precise_float).
  • Integer buffers: matrix/TRC chains use per-code input tables + matrix + output tables indexed by x^(1/4) (accurate near black even for pure gamma curves); chains containing a CLUT are sampled into a device link (33³ for 3 inputs, 17⁴ for 4, 4096 for 1) evaluated with tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon (native only).
  • GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus offsets, and 4096-entry output curves indexed by x^(1/4). Each channel is interpolated separately using float texels; SDR input/output are clamped to [0, 1]. A final RGB 33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
  • Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute colorimetric scales by media white (wtpt; v2 display profiles treated as D50; a zero, negative or non-finite white is refused with CmsError::Invalid); BPC uses neutral black points (Y only) mapped linearly in XYZ.

Built-in profiles (all CC0-1.0, generated by this crate)

id description
srgb sRGB IEC61966-2.1 (Rec. 709 primaries, D65 Bradford-adapted, sRGB TRC)
display-p3 Display P3
adobe-rgb-compat Adobe RGB (1998)-compatible (published primaries, gamma 563/256)
prophoto-compat ProPhoto-compatible (ROMM primaries, D50, gamma 1.8)
linear-srgb sRGB primaries, linear
rec2020 Rec. 2020 primaries, Rec. 709 OETF
gray-gamma-2.2, sgray gray gamma 2.2; gray with the sRGB curve (default gray working space)
lab-d50 Lab identity (v4 encoding)
coated-cmyk PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR) — default CMYK

The CMYK profile

Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry redistribution conditions or unclear licences, so PhotoCraft generates its own (src/synth.rs): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY, 17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink limit 300 %, perceptual tables with L* black-point scaling and soft chroma compression against a sampled gamut boundary. profiles/photocraft-coated-cmyk.icc (237 KB, AToB 11⁴ lut16, BToA 21³ lut16) is that output, dedicated to the public domain (CC0-1.0). Regenerate with

PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen::

It is a plausible coated-offset profile, not a measured characterisation: use your printer's profile for production print.

Not implemented / approximate

  • v4.4 mpet (multiProcessElements, D2Bx/B2Dx) tags, named colour, device link and abstract profiles as transform endpoints, chad-based adaptation of v2 absolute colorimetry.
  • BPC destination black uses the BToA/AToB round trip directly (Adobe's curve fit for ill-behaved LUT profiles is not implemented); v2/v4 perceptual reference black mixing is not modelled beyond the PRMG black for v4 LUT profiles.
  • Profile ID (MD5) is not computed when writing (left zero, which the spec allows).