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.
This commit is contained in:
aiongg
2026-10-13 03:22:39 +00:00
parent d282bd06c6
commit 9e2b2b03d8
3 changed files with 111 additions and 45 deletions
+38 -15
View File
@@ -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())
```
+33 -19
View File
@@ -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<Builtin> {
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
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<Builtin> {
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 {
+40 -11
View File
@@ -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<f32> = (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::<f32>()).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}");
}
}
}