diff --git a/crates/cms/README.md b/crates/cms/README.md index 68267585..83455684 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -35,8 +35,9 @@ Buffers are checked once per call, before any pixel is written. * `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 for a profile a document names (see - [Selecting by profile name](#selecting-by-profile-name)). +* `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](#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. @@ -134,19 +135,28 @@ Use your printer's profile for production print. ### Selecting by profile name -`Builtin::from_profile_name` maps the profile description a document carries to a built-in, -ignoring case and whitespace: +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: -| Name in the document | Built-in | -|---|---| -| Coated FOGRA39 (ISO 12647-2:2004), Coated FOGRA39 | `coated-fogra39` | -| ISO Coated v2 (ECI), ISO Coated v2 300% (ECI) | `coated-fogra39` | -| each built-in's own description (e.g. sRGB IEC61966-2.1) | that built-in | -| anything else | `None` | +* `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. -ECI's ISO Coated v2 profiles are made from FOGRA39L, the same characterisation data as Adobe's -Coated FOGRA39, so they describe the same printed colours; only their separations (ink limit, -black generation) differ. +| 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 @@ -154,8 +164,21 @@ Page-layout and image editors usually convert CMYK for display with relative col black point compensation. To match them, keep BPC on (the `TransformOptions` default): ```rust -let cmyk = Builtin::from_profile_name(&doc_profile_name).unwrap_or(Builtin::CoatedFogra39); -let t = Transform::new(cmyk.profile(), Builtin::Srgb.profile(), Intent::RelativeColorimetric, /*bpc*/ true)?; +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()) ``` diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index a5939282..361f228c 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -46,7 +46,7 @@ pub enum Builtin { CoatedCmyk, /// Coated offset CMYK fitted to Fogra's FOGRA39 characterisation data (ISO 12647-2:2004 /// Amd 1, paper type 1/2): 330 % total ink, medium GCR. The colours of documents whose - /// CMYK profile is Coated FOGRA39 or ISO Coated v2 (see [`Builtin::from_profile_name`]). + /// CMYK profile is Coated FOGRA39 or ISO Coated v2 (see [`Builtin::stand_in_for`]). CoatedFogra39, } @@ -119,25 +119,34 @@ impl Builtin { alias.or_else(|| Builtin::ALL.into_iter().find(|b| b.description().eq_ignore_ascii_case(s))) } - /// The built-in that stands in for a profile a document names (its `desc` tag), compared - /// ignoring case and whitespace. - /// - /// * Profiles built on the FOGRA39 characterisation data map to - /// [`Builtin::CoatedFogra39`]: Adobe's "Coated FOGRA39 (ISO 12647-2:2004)" (also seen as - /// "Coated FOGRA39"), and ECI's "ISO Coated v2 (ECI)" and "ISO Coated v2 300% (ECI)", - /// which are made from FOGRA39L, the same data. They print the same colours; only - /// their separations (total ink limit, black generation) differ. - /// * Every built-in by its own description, e.g. "sRGB IEC61966-2.1". - /// - /// Unknown names give `None`: keep the document's embedded profile, or ask. + /// The built-in whose own description (its `desc` tag) is `name`, compared ignoring case + /// and whitespace, e.g. "sRGB IEC61966-2.1" gives [`Builtin::Srgb`]. Only exact matches: + /// for a built-in that approximates another profile, see [`Builtin::stand_in_for`]. pub fn from_profile_name(name: &str) -> Option { - let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); - let n = key(name); - const FOGRA39: [&str; 4] = ["Coated FOGRA39 (ISO 12647-2:2004)", "Coated FOGRA39", "ISO Coated v2 (ECI)", "ISO Coated v2 300% (ECI)"]; - if FOGRA39.iter().any(|f| key(f) == n) { - return Some(Builtin::CoatedFogra39); - } - Builtin::ALL.into_iter().find(|b| key(b.description()) == n) + let n = profile_name_key(name); + Builtin::ALL.into_iter().find(|b| profile_name_key(b.description()) == n) + } + + /// The built-in to use in place of a profile a document names (its `desc` tag) when the + /// profile's bytes are not available, compared ignoring case and whitespace. + /// + /// * A built-in's own description gives that built-in ([`Builtin::from_profile_name`]). + /// * Profiles built on the FOGRA39 characterisation data give [`Builtin::CoatedFogra39`] + /// (330 % total ink, as ISO Coated v2): Adobe's "Coated FOGRA39 (ISO 12647-2:2004)" (also + /// seen as "Coated FOGRA39") and ECI's "ISO Coated v2 (ECI)", which is made from + /// FOGRA39L, the same data. + /// * "ISO Coated v2 300% (ECI)" and every other name give `None`: the built-in would + /// separate to more ink than that profile allows. + /// + /// The result is an approximation for display and separation. It describes the named + /// profile's printed colours, but its separations (black generation, ink distribution) are + /// not the named profile's. Prefer the document's own ICC bytes whenever it carries them. + pub fn stand_in_for(name: &str) -> Option { + const FOGRA39: [&str; 3] = ["Coated FOGRA39 (ISO 12647-2:2004)", "Coated FOGRA39", "ISO Coated v2 (ECI)"]; + Builtin::from_profile_name(name).or_else(|| { + let n = profile_name_key(name); + FOGRA39.iter().any(|f| profile_name_key(f) == n).then_some(Builtin::CoatedFogra39) + }) } /// The profile (built once per process). @@ -149,6 +158,11 @@ impl Builtin { } } +/// A profile description for comparison: lowercase, without whitespace. +fn profile_name_key(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect() +} + /// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50. pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> { Some(match cs { diff --git a/crates/cms/tests/fogra39.rs b/crates/cms/tests/fogra39.rs index 919e9318..f7fcdd0c 100644 --- a/crates/cms/tests/fogra39.rs +++ b/crates/cms/tests/fogra39.rs @@ -512,21 +512,50 @@ fn matches_reference_srgb_values() { #[test] fn builtins_by_document_profile_name() { - for name in [ - "Coated FOGRA39 (ISO 12647-2:2004)", - "Coated FOGRA39", - "ISO Coated v2 (ECI)", - "ISO Coated v2 300% (ECI)", - "iso coated V2 300 % (eci)", - "Coated FOGRA39 (fitted, craft-cms)", - ] { - assert_eq!(Builtin::from_profile_name(name), Some(Builtin::CoatedFogra39), "{name}"); - } for b in Builtin::ALL { assert_eq!(Builtin::from_profile_name(b.description()), Some(b), "{b:?}"); + assert_eq!(Builtin::stand_in_for(b.description()), Some(b), "{b:?}"); } assert_eq!(Builtin::from_profile_name(" srgb iec61966-2.1 "), Some(Builtin::Srgb)); - for name in ["U.S. Web Coated (SWOP) v2", "Coated FOGRA51 (PSO)", "PSO Coated v3", ""] { + assert_eq!(Builtin::from_profile_name("coated fogra39 (FITTED, craft-cms)"), Some(Builtin::CoatedFogra39)); + for name in FOGRA39_STAND_IN_NAMES.into_iter().chain(["coated FOGRA39 (iso 12647-2:2004)", "iso coated v2(eci)"]) { assert_eq!(Builtin::from_profile_name(name), None, "{name}"); + assert_eq!(Builtin::stand_in_for(name), Some(Builtin::CoatedFogra39), "{name}"); + } + // The built-in separates to up to 330 % total ink, more than this profile allows. + for name in ["ISO Coated v2 300% (ECI)", "iso coated V2 300 % (eci)", "U.S. Web Coated (SWOP) v2", "Coated FOGRA51 (PSO)", "PSO Coated v3", ""] { + assert_eq!(Builtin::from_profile_name(name), None, "{name}"); + assert_eq!(Builtin::stand_in_for(name), None, "{name}"); + } +} + +/// Names [`Builtin::stand_in_for`] maps to [`Builtin::CoatedFogra39`] (all 330 % profiles). +const FOGRA39_STAND_IN_NAMES: [&str; 3] = ["Coated FOGRA39 (ISO 12647-2:2004)", "Coated FOGRA39", "ISO Coated v2 (ECI)"]; + +/// Total ink of every separation through a stand-in stays within the named profiles' 330 % +/// limit: a 33³ sRGB grid, with BPC, for each intent the profile has tables for. +#[test] +fn stand_in_separations_respect_the_ink_limit() { + const N: usize = 33; + const LIMIT: f32 = 3.30 + 0.005; + let grid: Vec = (0..N * N * N).flat_map(|i| [i / (N * N), i / N % N, i % N].map(|v| v as f32 / (N - 1) as f32)).collect(); + for name in FOGRA39_STAND_IN_NAMES { + let cmyk = Builtin::stand_in_for(name).unwrap().profile(); + for intent in [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation] { + let t = Transform::new(Builtin::Srgb.profile(), cmyk, intent, true).unwrap(); + let mut out = vec![0.0f32; N * N * N * 4]; + t.convert_f32(&grid, 3, &mut out, 4, false).unwrap(); + let (i, max) = + out.chunks_exact(4).map(|c| c.iter().sum::()).enumerate().fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m }); + eprintln!("{name}, {intent:?}: max total ink {:.2} % at sRGB {:?}", max * 100.0, &grid[i * 3..i * 3 + 3]); + assert!(max <= LIMIT, "{name}, {intent:?}: total ink {max} at sRGB {:?}", &grid[i * 3..i * 3 + 3]); + + // sRGB black, the darkest separation (reviewed with perceptual: 329.75 %). + let mut black = [0.0f32; 4]; + t.eval(&[0.0, 0.0, 0.0], &mut black).unwrap(); + let total: f32 = black.iter().sum(); + eprintln!("{name}, {intent:?}: sRGB black → {black:?}, total {:.2} %", total * 100.0); + assert!(total <= LIMIT, "{name}, {intent:?}: sRGB black total ink {total}"); + } } }