Files
aiongg 9e2b2b03d8 craft-cms: separate exact profile-name matches from stand-ins
Builtin::from_profile_name matches only a built-in's own description.
Builtin::stand_in_for adds the FOGRA39 approximations (Coated FOGRA39, ISO
Coated v2) for documents that name a profile without carrying its bytes.
ISO Coated v2 300% (ECI) has no stand-in: the built-in separates to up to
330 % total ink. The README example parses the document's ICC bytes first
and handles a missing stand-in explicitly.
2026-10-13 03:22:39 +00:00

11 KiB
Raw Permalink Blame History

Craft CMS

craft-cms is a pure-Rust ICC colour management module for the Craft suite: profile parsing and writing, transforms between profiles, rendering intents, black point compensation, soft proofing, gamut checks, 3D LUTs and a set of built-in profiles. No C, no unsafe, and no dependency on other craft-libs crates. 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.

Extracted from PhotoCraft's crates/cms at b3222ee (refs #36).

API

use craft_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

Calls never panic on caller input: strides below the channel count, buffers that are not whole pixels, a destination with fewer pixels than the source, slices shorter than the channel count and out-of-range table or LUT dimensions return CmsError::InvalidArgument. Buffers are checked once per call, before any pixel is written.

  • 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::cached(src, dst, opts): process-wide transform cache.
  • lutfile::parse(name, bytes) / lutfile::write_cube(&lut): .cube, .3dl and .look LUT files; lutfile::builtin(id) returns generated looks (lutfile::BUILTIN).
  • Profile::to_bytes(): original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
  • Builtin::from_profile_name(desc): the built-in with that description (exact match); Builtin::stand_in_for(desc): the built-in that approximates a profile a document names when its ICC bytes are not available (see Selecting by profile name).
  • 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. content_hash() compares bytes and suits cache keys only.

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).
  • 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.
  • White: except in absolute colorimetric (and soft proofs that simulate paper), the source's device white comes out as exactly the destination's (no ink, or 1.0) on every path. LUT profiles rarely put white on a grid node, so the device link's white node is set to it and the exact and float paths compare each pixel with white; all other colours are left as the profiles give them. With a profile whose white falls between its nodes (coated-cmyk: 0.4 % C), the last step to white is that much larger than its neighbours.

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

The profile descriptions and copyright text keep the PhotoCraft names they were first published with, so profiles embedded in existing files still match byte for byte.

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
coated-fogra39 Coated FOGRA39 (fitted, craft-cms): fitted to Fogra's FOGRA39 data, 330% TAC, medium GCR

The CMYK profiles

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 this crate generates its own. Both share the separation code in src/synth.rs: GCR black generation (starts at 20 % gray component, K max 95 %), a bounded least-squares CMY solve under the total ink limit, and perceptual tables with L* black-point scaling and soft chroma compression against a sampled gamut boundary.

coated-cmyk (profiles/photocraft-coated-cmyk.icc, 237 KB, AToB 11⁴ lut16, BToA 21³ lut16) comes from 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 %) and a 300 % ink limit. It is a plausible coated-offset profile, not a measured characterisation: its midtones are darker than FOGRA39 (mean ΔE00 6.7 against the FOGRA39 patches, oranges such as 0/50/100/0 about 5 ΔE00 off).

coated-fogra39 (profiles/craft-coated-fogra39.icc, 713 KB, AToB 11⁴ lut16, BToA 33³ lut16, 330 % ink limit) reproduces the colours of documents set up for Coated FOGRA39 or ISO Coated v2. It is fitted to Fogra's FOGRA39 characterisation data (tests/data/FOGRA39.txt, 1617 IT8.7/4 patches, CMYK with D50 XYZ and Lab; ISO 12647-2:2004 Amd 1 offset on paper type 1/2): a Yule–Nielsen modified Neugebauer model with free overprints, per-ink tone curves and a fitted n (1.45) is solved by least squares against all patches (mean ΔE00 1.1 on its own), then a Gaussian-kernel ridge regression of the remaining Lab errors over CMYK (σ = 0.2, λ = 0.01) corrects it before the AToB table is sampled, with no ink mapped to exactly L* 100, a* = b* = 0. The BToA tables invert that AToB table; their input curves put that white on a grid node holding no ink (CmykParams::white_on_node), so the profile separates PCS white to within 2·10⁻⁵ ink on its own.

Through the AToB, relative colorimetric, against the 1617 FOGRA39 patches mean ΔE00 p95 max
coated-fogra39 0.07 0.18 0.53
coated-fogra39 fit, against the 10 % of patches held out of it 0.11 0.32 0.79
coated-cmyk 6.67 13.4 16.1

Lab → CMYK → Lab through the profile (relative colorimetric) returns colours inside the gamut within mean ΔE00 0.24 (p95 1.07, max 2.55). Converted to sRGB (relative colorimetric with black point compensation), process solids, overprints and oranges land within 0.8 ΔE00 of reference values for Coated FOGRA39 (tests/fogra39.rs).

"FOGRA39" identifies the reference data the profile is fitted to; it does not imply certification, approval or endorsement by Fogra. The data file is Fogra's, distributed unmodified with Fogra identified as the source as Fogra's terms require (see NOTICE); it is left out of the published crate, as only the tests read it.

Regenerate the profiles with

CRAFT_CMS_REGEN_PROFILES=1 cargo test -p craft-cms --release --test regen --test fogra39

Use your printer's profile for production print.

Selecting by profile name

Use the ICC bytes a document embeds or a user loads whenever there are any. When a document only names its profile (its desc tag), two functions map the name to a built-in, ignoring case and whitespace:

  • Builtin::from_profile_name: exact matches only, a built-in's own description.
  • Builtin::stand_in_for: the exact match, else a built-in that approximates the named profile.
Name in the document from_profile_name stand_in_for
each built-in's own description (e.g. sRGB IEC61966-2.1) that built-in that built-in
Coated FOGRA39 (ISO 12647-2:2004), Coated FOGRA39 None coated-fogra39
ISO Coated v2 (ECI) None coated-fogra39
ISO Coated v2 300% (ECI) None None
anything else None None

The stand-ins are made from the same characterisation data as the named profiles: ECI's ISO Coated v2 is made from FOGRA39L, the data of Adobe's Coated FOGRA39. They describe the same printed colours. The built-in separates to at most 330 % total ink, the limit of ISO Coated v2 (Adobe doesn't publish Coated FOGRA39's). Black generation and ink distribution differ, so separations through a stand-in are not the named profile's. ISO Coated v2 300% (ECI) has no stand-in: the built-in separates to up to 330 % total ink.

Display transforms

Page-layout and image editors usually convert CMYK for display with relative colorimetric and black point compensation. To match them, keep BPC on (the TransformOptions default):

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

// The document's own profile first; a stand-in only when it carries no ICC bytes.
let parsed;
let cmyk: &Profile = match doc_icc_bytes {
    Some(bytes) => {
        parsed = Profile::parse(bytes)?;
        &parsed
    }
    None => match Builtin::stand_in_for(&doc_profile_name) {
        Some(b) => b.profile(),
        None => return Err(UnknownProfile(doc_profile_name)), // e.g. ask the user for the profile
    },
};
let t = Transform::new(cmyk, Builtin::Srgb.profile(), Intent::RelativeColorimetric, /*bpc*/ true)?;
// or: transform::cached(src, dst, TransformOptions::default())

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