diff --git a/Cargo.lock b/Cargo.lock
index 6471411f0..ba814a834 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -657,6 +657,15 @@ dependencies = [
"objc2-app-kit 0.3.2",
]
+[[package]]
+name = "craft-separations"
+version = "0.1.0"
+dependencies = [
+ "craft-cms",
+ "craft-pdf",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-settings"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 868e568dd..2ba6ad5fe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,7 @@ craft-docpath = { path = "crates/docpath" }
craft-i18n = { path = "crates/i18n" }
craft-print = { path = "crates/print" }
craft-quit = { path = "crates/quit" }
+craft-separations = { path = "crates/separations" }
craft-settings = { path = "crates/settings" }
craft-ui = { path = "crates/ui" }
egui = { version = "0.36", default-features = false }
diff --git a/crates/separations/Cargo.toml b/crates/separations/Cargo.toml
new file mode 100644
index 000000000..4cdec570f
--- /dev/null
+++ b/crates/separations/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "craft-separations"
+description = "Ink model and per-ink plates rendered from a PDF, for the Craft apps' print output"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+craft-pdf = { workspace = true, features = ["raster"] }
+craft-cms = { workspace = true }
+thiserror = { workspace = true }
diff --git a/crates/separations/README.md b/crates/separations/README.md
new file mode 100644
index 000000000..9eef7eb8d
--- /dev/null
+++ b/crates/separations/README.md
@@ -0,0 +1,79 @@
+# craft-separations
+
+Print separations for the Craft apps:
+
+- `ink`: the ink model the writers and previews share. `InkSet` holds the four process inks
+ (always first, in plate order) and the spot inks of a job; `InkColor` is a colour as ink
+ tints (`Separation`, `DeviceN`, registration or none); `Overprint` holds `op`, `OP` and
+ `OPM`. Spot names are compared as PDF names: `/Gold#20Foil` and `Gold Foil` are one ink.
+- `plates`: one 8-bit plane per ink (255 = 100% ink) rendered from a PDF page.
+- `cms`: process colour conversion into a CMYK printing condition through craft-cms.
+
+## Plates from an exported PDF
+
+An app exports its PDF as usual, then separates a page of it:
+
+```rust
+use craft_separations::{plates, InkId, Options, Process};
+
+let p = plates(&pdf, 0, 150.0, &Options::default())?;
+for (id, ink) in p.inks.iter() {
+ let plane = p.plane(id); // p.width × p.height tints, row-major
+ println!("{}: {} pixels inked", ink.name(), plane.map_or(0, |t| t.iter().filter(|v| **v > 0).count()));
+}
+let black = p.tint(InkId::process(Process::Black), 10, 10);
+let gold = p.inks.lookup("Gold").and_then(|id| p.tint(id, 10, 10));
+```
+
+The plates are the PDF as printed, so a preview built on them shows what the writer produced,
+not what the document model intended.
+
+How a plate is made: the page renders once per ink through craft-pdf's `raster` with
+hayro-interpret's plate mode (`InterpreterSettings::plate`). Every paint draws its tint of
+that ink as gray film. A paint that overprints (`op`/`OP`, and `OPM 1` for zero DeviceCMYK
+components) without naming the ink leaves the film as it is; with `Options::overprint` off,
+every paint knocks out. Clipping, anti-aliasing, soft masks, transparency and knockout groups
+stay the renderer's own. `Separation` and `DeviceN` colorants paint their own plates (`All`
+every plate, `None` none), DeviceCMYK its components, DeviceGray black only.
+
+Inks are found by a low-resolution pass that records each spot ink a paint uses, in the order
+first painted. Spot inks used only inside soft masks are left out. Each spot gets a Lab
+alternate measured from its sRGB appearance at 100%, so out-of-sRGB spots are clipped.
+
+### Process colour
+
+RGB, CIE-based and ICCBased colours become CMYK through, in order:
+
+1. the CMYK profile of the page's or the document's output intent (a `GTS_PDFX` intent first),
+ when craft-cms can convert into it;
+2. `Options::fallback`: craft-cms's Coated FOGRA39 profile (the default), a profile the caller
+ gives, or the ISO 32000-2 §10.4.2.4 device formulas.
+
+`Plates::conversion` says which was used. Conversions are relative colorimetric without black
+point compensation. DeviceCMYK, and ICCBased CMYK in the printing condition's own profile,
+separate as they are.
+
+### Caps
+
+| Constant | Value | Why |
+|---|---|---|
+| `MAX_SPOT_INKS` | 32 spot inks per page | A `DeviceN` space has at most 32 colorants, and each plate costs a render and a byte per pixel. Further spots are listed in `Plates::unseparated`. |
+| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. The resolution is lowered to fit; `Plates::dpi` reports the one used. |
+
+The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolution.
+
+### Approximations
+
+- Non-separable blend modes and groups that blend in RGB are approximated per ink.
+- The graphics state's rendering intent is not read; transfer functions and halftones are not
+ applied.
+
+## Credits
+
+- Plate mode, ink discovery and the output-intent handling come from
+ [storytold/pdfcraft#2770](https://github.com/storytold/pdfcraft/pull/2770) by Guillem Hermida
+ (guillehr2), with craft-cms in place of moxcms.
+- Knockout groups in the renderer come from
+ [storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410.
+
+Both MIT OR Apache-2.0, as this crate.
diff --git a/crates/separations/src/cms.rs b/crates/separations/src/cms.rs
new file mode 100644
index 000000000..bf0d8d853
--- /dev/null
+++ b/crates/separations/src/cms.rs
@@ -0,0 +1,143 @@
+//! Process colour conversion for plate mode through craft-cms.
+
+use std::sync::{Arc, OnceLock};
+
+use craft_cms::{Builtin, ColorSpace, Intent, Profile, Transform, TransformOptions, builtin, cached, math};
+use craft_pdf::interpret::color::{CmykConversion, CmykSimulation};
+
+/// Converts process colour into a CMYK printing condition with craft-cms: relative
+/// colorimetric, without black point compensation, as the plate renderer expects (see
+/// [`CmykConversion`]). CMYK shows as sRGB relative colorimetric with black point
+/// compensation. Transforms are built on first use and shared through craft-cms's cache.
+pub struct CmsConversion {
+ profile: Profile,
+ perceptual: bool,
+ from_srgb: OnceLock>>,
+ from_lab: OnceLock >>,
+ to_srgb: OnceLock >>,
+}
+
+/// Why a profile can't be a printing condition.
+#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
+pub enum ProfileError {
+ #[error("the profile could not be read: {0}")]
+ Unreadable(String),
+ #[error("the profile is not CMYK")]
+ NotCmyk,
+ #[error("the profile has no PCS-to-device (BToA) tables")]
+ NoPcsToDevice,
+}
+
+impl CmsConversion {
+ /// The printing condition of ICC profile bytes `icc`.
+ pub fn new(icc: &[u8]) -> Result {
+ Self::from_profile(Profile::parse(icc).map_err(|e| ProfileError::Unreadable(format!("{e:?}")))?)
+ }
+
+ /// The printing condition of a parsed CMYK profile with PCS-to-device tables.
+ pub fn from_profile(profile: Profile) -> Result {
+ if profile.color_space != ColorSpace::Cmyk {
+ return Err(ProfileError::NotCmyk);
+ }
+ if profile.pcs_to_device(Intent::RelativeColorimetric).is_err() {
+ return Err(ProfileError::NoPcsToDevice);
+ }
+ // Without a colorimetric BToA1 table craft-cms uses BToA0, the perceptual one.
+ let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some();
+ Ok(Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() })
+ }
+
+ /// craft-cms's Coated FOGRA39 profile.
+ pub fn fogra39() -> Self {
+ let profile = Builtin::CoatedFogra39.profile().clone();
+ let perceptual = profile.b2a[1].is_none() && profile.b2a[0].is_some();
+ Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() }
+ }
+
+ /// A plate-mode printing condition converting through this, for the profile bytes `icc`
+ /// this was made from. ICCBased CMYK colours embedding those same bytes separate as they
+ /// are.
+ pub fn simulation(self, icc: &[u8]) -> CmykSimulation {
+ CmykSimulation::with_conversion(icc, self)
+ }
+
+ fn transform(cell: &OnceLock>>, src: &Profile, dst: &Profile, bpc: bool) -> Option> {
+ cell.get_or_init(|| {
+ // The fast path interpolates the same tables that 8-bit buffers use: plates convert
+ // images sample by sample through here.
+ let opts = TransformOptions { intent: Intent::RelativeColorimetric, bpc, precise_float: false };
+ cached(src, dst, opts).ok()
+ })
+ .clone()
+ }
+}
+
+fn whole(input: &[f32], channels: usize, cmyk: &[f32]) -> bool {
+ input.len().is_multiple_of(channels) && cmyk.len().is_multiple_of(4) && input.len() / channels == cmyk.len() / 4
+}
+
+fn finite01(cmyk: &mut [f32]) {
+ for v in cmyk {
+ *v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
+ }
+}
+
+impl CmykConversion for CmsConversion {
+ fn from_srgb(&self, rgb: &[f32], cmyk: &mut [f32]) -> Option<()> {
+ if !whole(rgb, 3, cmyk) {
+ return None;
+ }
+ let t = Self::transform(&self.from_srgb, builtin::srgb(), &self.profile, false)?;
+ t.convert_f32(rgb, 3, cmyk, 4, false).ok()?;
+ finite01(cmyk);
+ Some(())
+ }
+
+ fn from_pcs_xyz(&self, xyz: &[f32], cmyk: &mut [f32]) -> Option<()> {
+ if !whole(xyz, 3, cmyk) {
+ return None;
+ }
+ // Into the Lab D50 identity profile's device values (ICC v4 Lab encoding).
+ let lab: Vec = xyz
+ .as_chunks::<3>()
+ .0
+ .iter()
+ .flat_map(|c| {
+ let [l, a, b] = math::xyz_to_lab_f32(*c);
+ [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]
+ })
+ .collect();
+ let t = Self::transform(&self.from_lab, Builtin::LabD50.profile(), &self.profile, false)?;
+ t.convert_f32(&lab, 3, cmyk, 4, false).ok()?;
+ finite01(cmyk);
+ Some(())
+ }
+
+ fn to_srgb8(&self, cmyk: &[u8], rgb: &mut [u8]) -> Option<()> {
+ if !cmyk.len().is_multiple_of(4) || cmyk.len() / 4 * 3 != rgb.len() {
+ return None;
+ }
+ let t = Self::transform(&self.to_srgb, &self.profile, builtin::srgb(), true)?;
+ t.convert_u8(cmyk, 4, rgb, 3, false).ok()
+ }
+
+ fn description(&self) -> Option<&str> {
+ let d = self.profile.description.trim();
+ (!d.is_empty()).then_some(d)
+ }
+
+ fn perceptual(&self) -> bool {
+ self.perceptual
+ }
+}
+
+/// CIE L\*a\*b\* (D50) of an sRGB colour, relative colorimetric; `None` if craft-cms can't
+/// convert.
+pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> {
+ static T: OnceLock>> = OnceLock::new();
+ let t = CmsConversion::transform(&T, builtin::srgb(), Builtin::LabD50.profile(), false)?;
+ let mut lab = [0.0_f32; 3];
+ let rgb = rgb.map(|c| f32::from(c) / 255.0);
+ t.convert_f32(&rgb, 3, &mut lab, 3, false).ok()?;
+ Some([lab[0] * 100.0, lab[1] * 255.0 - 128.0, lab[2] * 255.0 - 128.0])
+}
diff --git a/crates/separations/src/ink.rs b/crates/separations/src/ink.rs
new file mode 100644
index 000000000..77da40b7f
--- /dev/null
+++ b/crates/separations/src/ink.rs
@@ -0,0 +1,261 @@
+//! The ink model: which inks a job prints with, and colours as tints of those inks.
+
+use std::borrow::Cow;
+
+/// A process ink, in plate order.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Process {
+ Cyan,
+ Magenta,
+ Yellow,
+ Black,
+}
+
+impl Process {
+ /// The four process inks, in plate order.
+ pub const ALL: [Process; 4] = [Process::Cyan, Process::Magenta, Process::Yellow, Process::Black];
+
+ /// The colorant name a `Separation` or `DeviceN` colour space gives this ink.
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Cyan => "Cyan",
+ Self::Magenta => "Magenta",
+ Self::Yellow => "Yellow",
+ Self::Black => "Black",
+ }
+ }
+
+ /// The process ink a colorant name denotes (ISO 32000-2 §8.6.6.4).
+ pub fn from_name(name: &str) -> Option {
+ Self::ALL.into_iter().find(|p| p.name() == name)
+ }
+
+ /// The ink's position in a CMYK colour.
+ pub fn index(self) -> usize {
+ self as usize
+ }
+}
+
+/// How a spot ink looks, for previews and for printing it as process colour.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Alternate {
+ /// CMYK components, `0.0..=1.0`.
+ Cmyk([f32; 4]),
+ /// CIE L\*a\*b\* relative to D50.
+ Lab([f32; 3]),
+}
+
+/// A spot ink.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Spot {
+ /// The colorant name, as a `Separation` colour space would name it, without PDF name
+ /// escapes (see [`normalize_name`]).
+ pub name: String,
+ /// The ink at 100%.
+ pub alternate: Alternate,
+ /// How opaque the ink is (`0.0..=1.0`, as InDesign's Ink Manager states it), when known.
+ pub solidity: Option,
+}
+
+/// A process or spot ink.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Ink {
+ Process(Process),
+ Spot(Spot),
+}
+
+impl Ink {
+ /// The colorant name: `Cyan` … `Black`, or the spot ink's name.
+ pub fn name(&self) -> &str {
+ match self {
+ Self::Process(p) => p.name(),
+ Self::Spot(s) => &s.name,
+ }
+ }
+
+ pub fn is_spot(&self) -> bool {
+ matches!(self, Self::Spot(_))
+ }
+}
+
+/// An ink's index in its [`InkSet`].
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct InkId(u16);
+
+impl InkId {
+ /// The ink's position in [`InkSet::iter`] order, which is also its plane in
+ /// [`Plates::planes`](crate::Plates::planes).
+ pub fn index(self) -> usize {
+ usize::from(self.0)
+ }
+
+ /// The id of process ink `p`, the same in every [`InkSet`].
+ pub fn process(p: Process) -> Self {
+ Self(p as u16)
+ }
+}
+
+/// The inks of one document or job: the four process inks, always first and in plate order,
+/// then spot inks in the order added. Ids stay valid as inks are added.
+#[derive(Clone, Debug, PartialEq)]
+pub struct InkSet {
+ inks: Vec,
+}
+
+impl Default for InkSet {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl InkSet {
+ /// The four process inks.
+ pub fn new() -> Self {
+ Self { inks: Process::ALL.into_iter().map(Ink::Process).collect() }
+ }
+
+ /// Adds `ink` and returns its id. An ink whose name is already in the set (compared after
+ /// [`normalize_name`]) is not added again, and its existing id is returned; a spot named
+ /// `Cyan` … `Black` is the process ink. A spot's name is stored normalised. `None` for the
+ /// names `All` and `None`, which are not inks (§8.6.6.4), and when the set is full
+ /// (65 536 inks).
+ pub fn add(&mut self, ink: Ink) -> Option {
+ let spot = match ink {
+ Ink::Process(p) => return Some(InkId::process(p)),
+ Ink::Spot(spot) => spot,
+ };
+ let name = normalize_name(&spot.name).into_owned();
+ if name == "All" || name == "None" {
+ return None;
+ }
+ if let Some(id) = self.lookup(&name) {
+ return Some(id);
+ }
+ let id = InkId(u16::try_from(self.inks.len()).ok()?);
+ self.inks.push(Ink::Spot(Spot { name, ..spot }));
+ Some(id)
+ }
+
+ /// The ink named `name`, compared after [`normalize_name`]: `"/Gold#20Foil"` finds
+ /// `Gold Foil`.
+ pub fn lookup(&self, name: &str) -> Option {
+ let name = normalize_name(name);
+ if let Some(p) = Process::from_name(&name) {
+ return Some(InkId::process(p));
+ }
+ let at = self.inks.iter().skip(4).position(|ink| ink.name() == name)?;
+ u16::try_from(at + 4).ok().map(InkId)
+ }
+
+ pub fn get(&self, id: InkId) -> Option<&Ink> {
+ self.inks.get(id.index())
+ }
+
+ /// Every ink with its id: the process inks, then the spot inks.
+ pub fn iter(&self) -> impl Iterator- {
+ // At most 65 536 inks (`add`), so every index fits.
+ self.inks.iter().enumerate().map(|(i, ink)| (InkId(i as u16), ink))
+ }
+
+ /// The spot inks with their ids.
+ pub fn spots(&self) -> impl Iterator
- {
+ self.iter().filter_map(|(id, ink)| match ink {
+ Ink::Spot(s) => Some((id, s)),
+ Ink::Process(_) => None,
+ })
+ }
+
+ /// The number of inks, process inks included.
+ pub fn len(&self) -> usize {
+ self.inks.len()
+ }
+
+ /// Always `false`: a set holds the process inks.
+ pub fn is_empty(&self) -> bool {
+ self.inks.is_empty()
+ }
+}
+
+/// A colour as ink tints.
+#[derive(Clone, Debug, PartialEq)]
+pub enum InkColor {
+ /// Tints `0.0..=1.0` of the listed inks; other inks are unspecified (they keep what is
+ /// under the paint when it overprints, and are knocked out otherwise). One entry is a
+ /// `Separation` colour, several a `DeviceN` one.
+ Tints(Vec<(InkId, f32)>),
+ /// The same tint on every plate: the registration colour (the `All` colorant).
+ All(f32),
+ /// No ink on any plate (the `None` colorant).
+ None,
+}
+
+/// Overprint parameters of a paint (ISO 32000-2 §8.6.7).
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct Overprint {
+ /// Fills and other non-stroking paints overprint (`op`).
+ pub fill: bool,
+ /// Strokes overprint (`OP`).
+ pub stroke: bool,
+ /// Nonzero overprint mode (`OPM 1`): a zero DeviceCMYK component leaves its plate as it is.
+ pub nonzero: bool,
+}
+
+/// A colorant name as a PDF name object carries it: without a leading `/` and with `#xx`
+/// escapes decoded (ISO 32000-2 §7.3.5), read as UTF-8 (invalid sequences replaced).
+pub fn normalize_name(name: &str) -> Cow<'_, str> {
+ let name = name.strip_prefix('/').unwrap_or(name);
+ if !name.contains('#') {
+ return Cow::Borrowed(name);
+ }
+ let bytes = name.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while let Some(&b) = bytes.get(i) {
+ let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
+ match (b, hex) {
+ (b'#', Some(decoded)) => {
+ out.push(decoded);
+ i += 3;
+ }
+ _ => {
+ out.push(b);
+ i += 1;
+ }
+ }
+ }
+ Cow::Owned(String::from_utf8_lossy(&out).into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn spot(name: &str) -> Ink {
+ Ink::Spot(Spot { name: name.into(), alternate: Alternate::Lab([80.0, 5.0, 60.0]), solidity: None })
+ }
+
+ #[test]
+ fn spots_follow_the_process_inks_and_names_are_compared_as_pdf_names() {
+ let mut set = InkSet::new();
+ let gold = set.add(spot("/Gold#20Foil")).unwrap();
+ assert_eq!(gold.index(), 4);
+ assert_eq!(set.get(gold).unwrap().name(), "Gold Foil");
+ assert_eq!(set.add(spot("Gold Foil")), Some(gold), "added once");
+ assert_eq!(set.lookup("Gold#20Foil"), Some(gold));
+ assert_eq!(set.add(spot("Black")), Some(InkId::process(Process::Black)));
+ assert_eq!(set.lookup("/Cyan"), Some(InkId::process(Process::Cyan)));
+ assert_eq!(set.add(spot("All")), None);
+ assert_eq!(set.add(spot("None")), None);
+ assert_eq!(set.lookup("gold foil"), None, "names are case-sensitive");
+ let names: Vec<&str> = set.iter().map(|(_, ink)| ink.name()).collect();
+ assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold Foil"]);
+ assert_eq!(set.spots().count(), 1);
+ }
+
+ #[test]
+ fn malformed_escapes_stay_as_written() {
+ assert_eq!(normalize_name("A#2"), "A#2");
+ assert_eq!(normalize_name("A#zz#41"), "A#zzA");
+ assert_eq!(normalize_name("#E2#82#AC"), "€");
+ }
+}
diff --git a/crates/separations/src/lib.rs b/crates/separations/src/lib.rs
new file mode 100644
index 000000000..73a131c72
--- /dev/null
+++ b/crates/separations/src/lib.rs
@@ -0,0 +1,12 @@
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+//! Print separations for the Craft apps: an ink model shared by the writers, plates rendered
+//! from a PDF, and process colour conversion through craft-cms. See the README.
+
+pub mod cms;
+pub mod ink;
+pub mod plates;
+
+pub use cms::{CmsConversion, ProfileError};
+pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name};
+pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates};
diff --git a/crates/separations/src/plates.rs b/crates/separations/src/plates.rs
new file mode 100644
index 000000000..33b72fac8
--- /dev/null
+++ b/crates/separations/src/plates.rs
@@ -0,0 +1,320 @@
+//! A PDF page separated into plates, one gray film per ink.
+//!
+//! A plate is the page rendered with hayro-interpret's plate mode
+//! (`InterpreterSettings::plate`): every paint draws its tint of one ink as gray, and a paint
+//! that overprints without naming the ink leaves the film as it is (ISO 32000-2 §8.6.7).
+//! Clipping, anti-aliasing, soft masks, transparency groups and blending stay the renderer's
+//! own. `Separation` and `DeviceN` colorants paint their own plates, DeviceCMYK its four
+//! components, DeviceGray black only, and other process colour converts to CMYK through the
+//! printing condition (see [`Conversion`]).
+//!
+//! Ported from PdfCraft #2770 by Guillem Hermida (`crates/render/src/separations.rs` at
+//! 6fcf2a4, MIT OR Apache-2.0), with craft-cms in place of moxcms.
+
+use std::collections::HashSet;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::{Arc, Mutex};
+
+use craft_pdf::interpret::InterpreterSettings;
+use craft_pdf::interpret::color::{CmykSimulation, Ink as PdfInk, InkSinkFn, Plate};
+use craft_pdf::raster::{self, MAX_SIDE, RenderCache};
+use craft_pdf::syntax::Pdf;
+use craft_pdf::syntax::object::dict::keys::{DEST_OUTPUT_PROFILE, OUTPUT_CONDITION, OUTPUT_CONDITION_IDENTIFIER, OUTPUT_INTENTS, S};
+use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfText};
+use craft_pdf::{OpenError, open_slice};
+
+use crate::cms::{CmsConversion, ProfileError, srgb8_to_lab};
+use crate::ink::{Alternate, Ink, InkId, InkSet, Spot};
+
+/// Spot inks separated per page, after the four process inks. More than any press runs: a
+/// `DeviceN` space has at most 32 colorants, and every plate costs a render and a byte per
+/// pixel. Further spot inks are listed in [`Plates::unseparated`].
+pub const MAX_SPOT_INKS: usize = 32;
+
+/// Device pixels per plate (4 MP, 2000 × 2000), so a page with many inks stays within a few
+/// hundred megabytes. The resolution is reduced to fit; [`Plates::dpi`] says what was used.
+pub const MAX_PLATE_PIXELS: f32 = 4.0e6;
+
+/// The resolution, in pixels per point, inks are discovered at before the plates render.
+const DISCOVERY_SCALE: f32 = 0.1;
+
+/// Spot ink names recorded while discovering, past which further names are dropped.
+const MAX_LISTED_SPOTS: usize = MAX_SPOT_INKS * 4;
+
+/// Output intents looked at per page: each can cost a profile's decoding and parsing.
+const MAX_OUTPUT_INTENTS: usize = 16;
+
+/// Decoding limit for an output intent's profile.
+const MAX_PROFILE_BYTES: usize = 16 << 20;
+
+/// The longest output condition kept, in characters.
+const MAX_CONDITION: usize = 160;
+
+/// The printing condition process colour converts to when the page has no usable output
+/// intent.
+#[derive(Clone, Debug, Default)]
+pub enum Fallback {
+ /// craft-cms's Coated FOGRA39 profile.
+ #[default]
+ Fogra39,
+ /// The caller's CMYK ICC profile.
+ Profile(Arc<[u8]>),
+ /// The ISO 32000-2 §10.4.2.4 device formulas, black generation and undercolour removal the
+ /// identity: neutral RGB goes to black alone, ICCBased CMYK separates as it is.
+ DeviceFormulas,
+}
+
+/// How [`plates`] separates.
+#[derive(Clone, Debug)]
+pub struct Options {
+ /// Honour `OP`, `op` and `OPM` as a separating device does. Without it every paint knocks
+ /// out what is under it.
+ pub overprint: bool,
+ /// The printing condition when the page has no output intent with a usable CMYK profile.
+ pub fallback: Fallback,
+}
+
+impl Default for Options {
+ fn default() -> Self {
+ Self { overprint: true, fallback: Fallback::default() }
+ }
+}
+
+/// How process colour that isn't CMYK already became CMYK.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Conversion {
+ /// Through the CMYK profile of the page's or document's output intent (ISO 32000-2
+ /// §14.11.5).
+ OutputIntent {
+ /// The profile's description, else the intent's output condition.
+ profile: String,
+ /// The intent's `/OutputConditionIdentifier`, such as `FOGRA39`.
+ condition: Option
,
+ },
+ /// Through [`Fallback::Fogra39`] or [`Fallback::Profile`]; the profile's description.
+ Fallback { profile: String },
+ /// By [`Fallback::DeviceFormulas`].
+ DeviceFormulas,
+}
+
+/// A page separated into plates.
+#[derive(Clone, Debug)]
+pub struct Plates {
+ /// The process inks, then the spot inks the page paints, in the order first painted. Spots
+ /// carry a Lab alternate measured from their sRGB appearance at 100%.
+ pub inks: InkSet,
+ /// Spot inks past [`MAX_SPOT_INKS`]: listed, not separated.
+ pub unseparated: Vec,
+ pub width: u32,
+ pub height: u32,
+ /// The resolution the plates rendered at, after [`MAX_PLATE_PIXELS`].
+ pub dpi: f32,
+ pub conversion: Conversion,
+ /// One plane per ink, in [`InkSet::iter`] order: the tint per pixel, row-major, 0 for no
+ /// ink and 255 for 100%.
+ pub planes: Vec>,
+}
+
+impl Plates {
+ /// Ink `id`'s plane.
+ pub fn plane(&self, id: InkId) -> Option<&[u8]> {
+ self.planes.get(id.index()).map(Vec::as_slice)
+ }
+
+ /// Ink `id`'s tint at pixel (`x`, `y`), 0–255.
+ pub fn tint(&self, id: InkId, x: u32, y: u32) -> Option {
+ if x >= self.width || y >= self.height {
+ return None;
+ }
+ self.plane(id)?.get(y as usize * self.width as usize + x as usize).copied()
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error(transparent)]
+ Open(#[from] OpenError),
+ #[error("page {0} does not exist")]
+ NoPage(usize),
+ #[error("the resolution must be positive and finite")]
+ InvalidDpi,
+ #[error("the fallback profile can't be a printing condition: {0}")]
+ Profile(#[from] ProfileError),
+ /// After a [`raster::Error::BackendPanic`] the document is dropped; nothing else is kept.
+ #[error(transparent)]
+ Render(#[from] raster::Error),
+}
+
+/// Separates page `page` (from 0) of `pdf` at `dpi`, reduced to fit [`MAX_PLATE_PIXELS`] and
+/// the renderer's side limit: one plate per ink, the four process inks and up to
+/// [`MAX_SPOT_INKS`] spot inks the page paints. Spot inks only soft masks use are left out
+/// (masks don't print). Never panics.
+pub fn plates(pdf: &[u8], page: usize, dpi: f32, opts: &Options) -> Result {
+ if !dpi.is_finite() || dpi <= 0.0 {
+ return Err(Error::InvalidDpi);
+ }
+ let doc = open_slice(pdf, None)?;
+ let (simulation, conversion) = match catch_unwind(AssertUnwindSafe(|| output_intent(&doc, page))) {
+ Ok(Some(found)) => (Some(found.0), found.1),
+ Ok(None) | Err(_) => fallback(&opts.fallback)?,
+ };
+ let pages = doc.pages();
+ let p = pages.get(page).ok_or(Error::NoPage(page))?;
+ let (w, h) = p.render_dimensions();
+ let scale = plate_scale(w, h, dpi / 72.0);
+ let cache = RenderCache::new();
+
+ let log = Arc::new(Mutex::new(SpotLog::default()));
+ let discover = InterpreterSettings { ink_sink: Some(spot_sink(&log)), ..Default::default() };
+ render(p, &cache, &discover, DISCOVERY_SCALE.min(scale))?;
+ let spots = std::mem::take(&mut log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).order);
+
+ let mut inks = InkSet::new();
+ let mut separated: Vec = PdfInk::PROCESS.to_vec();
+ let mut unseparated = Vec::new();
+ for (ink, swatch) in spots {
+ if separated.len() >= 4 + MAX_SPOT_INKS {
+ unseparated.push(ink.name().to_string());
+ continue;
+ }
+ let alternate = Alternate::Lab(srgb8_to_lab(swatch).unwrap_or([0.0; 3]));
+ let spot = Ink::Spot(Spot { name: ink.name().to_string(), alternate, solidity: None });
+ // A name that normalises to one already listed (`A#2341` and `AA`) has no plate of its
+ // own.
+ match inks.add(spot) {
+ Some(id) if id.index() == separated.len() => separated.push(ink),
+ _ => unseparated.push(ink.name().to_string()),
+ }
+ }
+
+ let mut size = None;
+ let mut planes = Vec::with_capacity(separated.len());
+ for ink in separated {
+ let plate = Plate { ink, overprint: opts.overprint, simulation: simulation.clone() };
+ let settings = InterpreterSettings { plate: Some(plate), ..Default::default() };
+ let film = render(p, &cache, &settings, scale)?;
+ size = Some((film.width, film.height));
+ // The film is gray on white: its red channel is the paper left uncovered.
+ planes.push(film.bytes().as_chunks::<4>().0.iter().map(|[red, ..]| 255 - red).collect());
+ }
+ let (width, height) = size.unwrap_or((0, 0));
+ Ok(Plates { inks, unseparated, width, height, dpi: scale * 72.0, conversion, planes })
+}
+
+fn render<'a>(
+ page: &'a craft_pdf::syntax::page::Page<'a>,
+ cache: &RenderCache<'a>,
+ settings: &InterpreterSettings,
+ scale: f32,
+) -> Result {
+ // The plate's gray film must keep its tints: no colour replacement.
+ let options = raster::Options { scale: [scale, scale], background: [255; 4], color_replacement: None, ..Default::default() };
+ Ok(raster::render(page, cache, settings, &options)?)
+}
+
+/// The scale a `w` × `h` pt page separates at: `scale`, within [`MAX_PLATE_PIXELS`] and
+/// [`MAX_SIDE`].
+fn plate_scale(w: f32, h: f32, scale: f32) -> f32 {
+ let (w, h) = (w.max(1.0), h.max(1.0));
+ let by_area = (MAX_PLATE_PIXELS / (w * h)).sqrt();
+ let by_side = MAX_SIDE as f32 / w.max(h);
+ let s = scale.min(by_area).min(by_side);
+ if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE }
+}
+
+fn fallback(fallback: &Fallback) -> Result<(Option, Conversion), Error> {
+ let (conversion, icc): (CmsConversion, &[u8]) = match fallback {
+ Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)),
+ Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC),
+ Fallback::Profile(icc) => (CmsConversion::new(icc)?, icc),
+ };
+ let simulation = conversion.simulation(icc);
+ let profile = simulation.description().unwrap_or("unnamed CMYK profile").to_string();
+ Ok((Some(simulation), Conversion::Fallback { profile }))
+}
+
+/// Spot inks reported while a page renders, in the order first painted, each with its sRGB
+/// appearance at 100%.
+#[derive(Default)]
+struct SpotLog {
+ order: Vec<(PdfInk, [u8; 3])>,
+ seen: HashSet,
+}
+
+fn spot_sink(log: &Arc>) -> InkSinkFn {
+ let log = log.clone();
+ Arc::new(move |ink: &PdfInk, appearance: &dyn Fn(f32) -> [u8; 3]| {
+ let mut log = log.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ if log.seen.contains(ink) || log.order.len() >= MAX_LISTED_SPOTS {
+ return;
+ }
+ log.seen.insert(ink.clone());
+ log.order.push((ink.clone(), appearance(1.0)));
+ })
+}
+
+/// A text string entry of `dict`, without control characters and at most [`MAX_CONDITION`]
+/// characters long; `None` when missing or blank.
+fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option {
+ let s = dict.get::>(key)?;
+ let text: String = pdf_text(s.as_bytes()).chars().filter(|c| !c.is_control()).take(MAX_CONDITION).collect();
+ let text = text.trim();
+ (!text.is_empty()).then(|| text.to_string())
+}
+
+/// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else
+/// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation).
+fn pdf_text(bytes: &[u8]) -> String {
+ if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) {
+ let units: Vec = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
+ return String::from_utf16_lossy(&units);
+ }
+ if let Some(utf8) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
+ return String::from_utf8_lossy(utf8).into_owned();
+ }
+ bytes.iter().map(|b| char::from(*b)).collect()
+}
+
+/// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own
+/// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a
+/// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others.
+fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> {
+ fn list<'a>(intents: Option>) -> Vec> {
+ intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect()
+ }
+ let xref = pdf.xref();
+ let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS)));
+ if listed.is_empty() {
+ listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS)));
+ }
+ // A stable sort: the PDF/X intent first, the others in their order.
+ listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
+ listed.iter().find_map(|intent| {
+ let icc = intent.get::>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?;
+ let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc);
+ let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER);
+ let profile = simulation
+ .description()
+ .map(str::to_string)
+ .or_else(|| text_entry(intent, OUTPUT_CONDITION))
+ .or_else(|| condition.clone())
+ .unwrap_or_else(|| "unnamed CMYK profile".to_string());
+ Some((simulation, Conversion::OutputIntent { profile, condition }))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn plates_fit_the_pixel_and_side_caps() {
+ assert_eq!(plate_scale(40.0, 40.0, 1.0), 1.0);
+ // A4 at 2400 dpi would be 22 000 × 31 000 pixels.
+ let s = plate_scale(595.0, 842.0, 2400.0 / 72.0);
+ assert!((595.0 * s) * (842.0 * s) <= MAX_PLATE_PIXELS * 1.0001);
+ // A 1 × 10 000 pt strip fits the area cap but not the renderer's side limit.
+ assert!(10_000.0 * plate_scale(1.0, 10_000.0, 1.0) <= MAX_SIDE as f32);
+ assert!(plate_scale(0.0, f32::NAN, 1.0) > 0.0);
+ }
+}
diff --git a/crates/separations/tests/plates.rs b/crates/separations/tests/plates.rs
new file mode 100644
index 000000000..fb035e572
--- /dev/null
+++ b/crates/separations/tests/plates.rs
@@ -0,0 +1,147 @@
+use std::sync::Arc;
+
+use craft_cms::{Intent, Profile, Transform, builtin};
+use craft_separations::{Alternate, Conversion, Error, Fallback, Ink, InkId, MAX_SPOT_INKS, Options, Process, plates};
+
+/// A 40 × 40 pt page with `content` and `resources`, `catalog` entries, and `objects`
+/// numbered from 5 (each a whole object body).
+fn page_pdf(content: &str, resources: &str, catalog: &str, objects: &[Vec]) -> Vec {
+ let mut out = b"%PDF-1.7\n".to_vec();
+ out.extend_from_slice(format!("1 0 obj << /Type /Catalog /Pages 2 0 R {catalog} >> endobj\n").as_bytes());
+ out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n");
+ out.extend_from_slice(
+ format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(),
+ );
+ out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
+ for (i, body) in objects.iter().enumerate() {
+ out.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes());
+ out.extend_from_slice(body);
+ out.extend_from_slice(b" endobj\n");
+ }
+ out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
+ out
+}
+
+fn stream(dict: &str, data: &[u8]) -> Vec {
+ let mut out = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
+ out.extend_from_slice(data);
+ out.extend_from_slice(b"\nendstream");
+ out
+}
+
+/// A spot colour whose alternate is a yellowish CMYK.
+const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.3 1 0] /N 1 >>]";
+
+/// Black over the page, Gold at 50% in the middle square, overprinting when `gs` sets `op`.
+fn black_and_gold(gs: &str) -> Vec {
+ let resources = format!("/ColorSpace << /CS0 {GOLD} >> /ExtGState << /OP << /op true >> >>");
+ page_pdf(&format!("0 0 0 1 k 0 0 40 40 re f {gs} /CS0 cs 0.5 scn 10 10 20 20 re f"), &resources, "", &[])
+}
+
+fn black() -> InkId {
+ InkId::process(Process::Black)
+}
+
+#[test]
+fn a_black_and_gold_page_separates_into_the_process_plates_and_gold() {
+ let p = plates(&black_and_gold(""), 0, 72.0, &Options::default()).unwrap();
+ assert_eq!((p.width, p.height, p.dpi), (40, 40, 72.0));
+ let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect();
+ assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
+ assert_eq!(p.planes.len(), 5);
+ assert!(p.unseparated.is_empty());
+ let gold = p.inks.lookup("Gold").unwrap();
+ let Some(Ink::Spot(spot)) = p.inks.get(gold) else { panic!("Gold is a spot ink") };
+ let Alternate::Lab([l, _, b]) = spot.alternate else { panic!("a Lab alternate") };
+ assert!(l > 60.0 && b > 40.0, "Gold looks yellow: {:?}", spot.alternate);
+
+ let half = |t: Option| t.is_some_and(|t| t.abs_diff(128) <= 1);
+ assert!(half(p.tint(gold, 20, 20)), "Gold at 50%");
+ assert_eq!(p.tint(black(), 20, 20), Some(0), "Gold knocks out black");
+ assert_eq!(p.tint(black(), 2, 2), Some(255));
+ assert_eq!(p.tint(gold, 2, 2), Some(0));
+ for c in [Process::Cyan, Process::Magenta, Process::Yellow] {
+ assert!(p.plane(InkId::process(c)).unwrap().iter().all(|t| *t == 0), "{c:?} is empty");
+ }
+
+ let over = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options::default()).unwrap();
+ assert!(half(over.tint(gold, 20, 20)));
+ assert_eq!(over.tint(black(), 20, 20), Some(255), "overprinted");
+ let off = plates(&black_and_gold("/OP gs"), 0, 72.0, &Options { overprint: false, ..Options::default() }).unwrap();
+ assert_eq!(off.tint(black(), 20, 20), Some(0), "overprint off: knocked out");
+}
+
+/// What `profile` prints for sRGB red, as tint bytes.
+fn red_in(profile: &[u8]) -> [u8; 4] {
+ let t = Transform::new(builtin::srgb(), &Profile::parse(profile).unwrap(), Intent::RelativeColorimetric, false).unwrap();
+ let mut cmyk = [0.0_f32; 4];
+ t.convert_f32(&[1.0, 0.0, 0.0], 3, &mut cmyk, 4, false).unwrap();
+ cmyk.map(|v| (v * 255.0).round() as u8)
+}
+
+fn red_tints(p: &craft_separations::Plates) -> [u8; 4] {
+ Process::ALL.map(|c| p.tint(InkId::process(c), 20, 20).unwrap())
+}
+
+fn near(got: [u8; 4], expected: [u8; 4]) -> bool {
+ got.iter().zip(expected).all(|(g, e)| g.abs_diff(e) <= 3)
+}
+
+#[test]
+fn process_colour_converts_through_the_output_intent_else_the_fallback() {
+ let synthetic = builtin::COATED_CMYK_ICC;
+ let intent = "/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Synthetic press) /DestOutputProfile 5 0 R >>]";
+ let red = |catalog: &str| page_pdf("1 0 0 rg 0 0 40 40 re f", "", catalog, &[stream("/N 4", synthetic)]);
+
+ let p = plates(&red(intent), 0, 72.0, &Options::default()).unwrap();
+ let profile = craft_cms::synth::DESCRIPTION.to_string();
+ assert_eq!(p.conversion, Conversion::OutputIntent { profile: profile.clone(), condition: Some("Synthetic press".into()) });
+ let expected = red_in(synthetic);
+ assert!(near(red_tints(&p), expected), "{:?}, the output intent prints {expected:?}", red_tints(&p));
+
+ let fogra = plates(&red(""), 0, 72.0, &Options::default()).unwrap();
+ assert_eq!(fogra.conversion, Conversion::Fallback { profile: builtin::COATED_FOGRA39_DESCRIPTION.into() });
+ let expected = red_in(builtin::COATED_FOGRA39_ICC);
+ assert!(near(red_tints(&fogra), expected), "{:?}, FOGRA39 prints {expected:?}", red_tints(&fogra));
+
+ let callers = Options { fallback: Fallback::Profile(Arc::from(synthetic)), ..Options::default() };
+ assert_eq!(plates(&red(""), 0, 72.0, &callers).unwrap().conversion, Conversion::Fallback { profile });
+
+ let formulas = Options { fallback: Fallback::DeviceFormulas, ..Options::default() };
+ let device = plates(&red(""), 0, 72.0, &formulas).unwrap();
+ assert_eq!((device.conversion.clone(), red_tints(&device)), (Conversion::DeviceFormulas, [0, 255, 255, 0]));
+
+ let broken = Options { fallback: Fallback::Profile(Arc::from(&b"not a profile"[..])), ..Options::default() };
+ assert!(matches!(plates(&red(""), 0, 72.0, &broken), Err(Error::Profile(_))));
+}
+
+#[test]
+fn spot_inks_past_the_cap_are_listed_not_separated_and_bad_input_is_refused() {
+ let n = MAX_SPOT_INKS + 2;
+ let spaces: String =
+ (0..n).map(|i| format!("/S{i} [/Separation /Spot{i} /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] ")).collect();
+ let content: String = (0..n).map(|i| format!("/S{i} cs 1 scn {i} 0 1 1 re f ")).collect();
+ let pdf = page_pdf(&content, &format!("/ColorSpace << {spaces} >>"), "", &[]);
+ let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
+ assert_eq!((p.inks.len(), p.planes.len()), (4 + MAX_SPOT_INKS, 4 + MAX_SPOT_INKS));
+ assert_eq!(p.unseparated, [format!("Spot{}", n - 2), format!("Spot{}", n - 1)]);
+
+ assert!(matches!(plates(&pdf, 1, 72.0, &Options::default()), Err(Error::NoPage(1))));
+ assert!(matches!(plates(&pdf, 0, f32::NAN, &Options::default()), Err(Error::InvalidDpi)));
+ assert!(matches!(plates(b"not a pdf", 0, 72.0, &Options::default()), Err(Error::Open(_))));
+}
+
+#[test]
+fn lab_colours_convert_through_the_profile_and_lab_white_is_paper() {
+ let resources = "/ColorSpace << /D50 [/Lab << /WhitePoint [0.9642 1 0.8249] >>] /D65 [/Lab << /WhitePoint [0.9505 1 1.089] >>] >>";
+ let pdf = page_pdf("/D50 cs 50 20 -10 scn 0 0 20 40 re f /D65 cs 100 0 0 scn 20 0 20 40 re f", resources, "", &[]);
+ let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap();
+ let at = |x| Process::ALL.map(|c| p.tint(InkId::process(c), x, 20).unwrap());
+ let fogra = Profile::parse(builtin::COATED_FOGRA39_ICC).unwrap();
+ let t = Transform::new(craft_cms::Builtin::LabD50.profile(), &fogra, Intent::RelativeColorimetric, false).unwrap();
+ let mut cmyk = [0.0_f32; 4];
+ t.convert_f32(&[0.5, 148.0 / 255.0, 118.0 / 255.0], 3, &mut cmyk, 4, false).unwrap();
+ let expected = cmyk.map(|v| (v * 255.0).round() as u8);
+ assert!(near(at(10), expected), "{:?}, FOGRA39 prints {expected:?}", at(10));
+ assert_eq!(at(30), [0, 0, 0, 0], "a Lab space's own white prints as paper");
+}