mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
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:
+38
-15
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user