From c025c0dfffef74ca8127d519afe5381032622d51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:15:36 +0000 Subject: [PATCH] separations: add craft-separations with the ink model and plates The ink model (process and spot inks, InkSet with PDF name lookup, InkColor, Overprint) and plates(pdf, page, dpi, opts): ink discovery and one gray film per ink through craft-pdf's raster plate mode, with process colour converted through craft-cms (output intent, else the caller's profile or FOGRA39). Plate rendering, ink discovery, caps and output-intent selection port pdfcraft #2770's separations.rs. Co-authored-by: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> --- Cargo.lock | 9 + Cargo.toml | 1 + crates/separations/Cargo.toml | 16 ++ crates/separations/README.md | 79 +++++++ crates/separations/src/cms.rs | 143 +++++++++++++ crates/separations/src/ink.rs | 261 +++++++++++++++++++++++ crates/separations/src/lib.rs | 12 ++ crates/separations/src/plates.rs | 320 +++++++++++++++++++++++++++++ crates/separations/tests/plates.rs | 147 +++++++++++++ 9 files changed, 988 insertions(+) create mode 100644 crates/separations/Cargo.toml create mode 100644 crates/separations/README.md create mode 100644 crates/separations/src/cms.rs create mode 100644 crates/separations/src/ink.rs create mode 100644 crates/separations/src/lib.rs create mode 100644 crates/separations/src/plates.rs create mode 100644 crates/separations/tests/plates.rs 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"); +}