diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 46733e0b3..80b1e08f7 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -12,6 +12,7 @@ Prepress for the Craft apps: separations, ink limits and the ink manager. - `coverage`: ink coverage at a pixel and over a page, total area coverage (TAC) per pixel, and the pixels and regions over a TAC limit. - `composite`: an sRGB preview of chosen plates. +- `output`: the chosen plates as they print, in an output profile (a printer's RGB, gray or CMYK). - `cms`: process color conversion into a CMYK printing condition through craft-cms. - `marks`: printer's marks for a page with a slug. `Marks::new(trim, bleed, &MarkOptions)` lays out crop marks, bleed marks (when the bleed passes the trim), a registration target on each @@ -139,6 +140,33 @@ over.paint(&mut rgb, [255, 0, 255]); // TAC warning let worst = over.regions().first().copied(); ``` +## Composite in an output profile + +`Plates::composite_to(enabled, &output)` returns the enabled inks in an output profile, for a +print or a proof. `Output::new(press, output, intent, bpc)` takes the press's CMYK profile +(`CmsConversion::profile`) and a gray, RGB or CMYK output profile: + +- the process plates convert from the press straight to the output (CMYK to PCS to output), + not through sRGB, at `intent`, with black point compensation when `bpc`. Relative + colorimetric with `bpc` prints the press's paper as the output's paper and its black as the + output's black; absolute colorimetric keeps the press paper's tint; +- each enabled spot plate combines over them, as in the preview, with its appearance converted + into the output: a multiply in a gray or RGB output, a multiply of the complements in a CMYK + output, so inks add. + +A CMYK output re-separates through its own tables, so a black-only press colour comes back as +the output's black generation, as it would on that printer. + +```rust +use craft_cms::{Intent, Profile}; +use craft_prepress::{CmsConversion, Output}; + +let press = CmsConversion::fogra39(); +let printer = Profile::parse(&printer_icc)?; +let output = Output::new(press.profile(), &printer, Intent::RelativeColorimetric, true)?; +let bytes = p.composite_to(&[], &output); // output.channels() bytes per pixel +``` + ## Ink manager `InkSet` carries the job's ink settings next to its inks: diff --git a/crates/prepress/src/cms.rs b/crates/prepress/src/cms.rs index 97c5a74dd..b94d48cbd 100644 --- a/crates/prepress/src/cms.rs +++ b/crates/prepress/src/cms.rs @@ -55,6 +55,11 @@ impl CmsConversion { Self { profile, perceptual, from_srgb: OnceLock::new(), from_lab: OnceLock::new(), to_srgb: OnceLock::new() } } + /// The printing condition's profile, the press an [`Output`](crate::Output) converts from. + pub fn profile(&self) -> &Profile { + &self.profile + } + /// A plate-mode printing condition converting through this, for the profile bytes `icc` /// this was made from. ICCBased CMYK colors embedding those same bytes separate as they /// are. @@ -99,15 +104,7 @@ impl CmykConversion for CmsConversion { 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 lab: Vec = xyz.as_chunks::<3>().0.iter().flat_map(|c| encode_lab(math::xyz_to_lab_f32(*c))).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); @@ -132,6 +129,11 @@ impl CmykConversion for CmsConversion { } } +/// CIE L\*a\*b\* as the Lab D50 identity profile's device values (ICC v4 Lab encoding). +pub(crate) fn encode_lab([l, a, b]: [f32; 3]) -> [f32; 3] { + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] +} + /// CIE L\*a\*b\* (D50) of an sRGB color, relative colorimetric; `None` if craft-cms can't /// convert. pub(crate) fn srgb8_to_lab(rgb: [u8; 3]) -> Option<[f32; 3]> { @@ -151,8 +153,7 @@ pub(crate) fn lab_to_srgb8(lab: &[f32], rgb: &mut [u8]) -> Option<()> { return None; } let t = CmsConversion::transform(&T, Builtin::LabD50.profile(), builtin::srgb(), false)?; - // The Lab D50 identity profile's device values (ICC v4 Lab encoding). - let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|[l, a, b]| [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]).collect(); + let encoded: Vec = lab.as_chunks::<3>().0.iter().flat_map(|c| encode_lab(*c)).collect(); let mut out = vec![0.0_f32; encoded.len()]; t.convert_f32(&encoded, 3, &mut out, 3, false).ok()?; for (byte, v) in rgb.iter_mut().zip(out) { diff --git a/crates/prepress/src/composite.rs b/crates/prepress/src/composite.rs index fd5d48ec0..c9cfa3a20 100644 --- a/crates/prepress/src/composite.rs +++ b/crates/prepress/src/composite.rs @@ -13,7 +13,7 @@ use crate::{Alternate, InkId, Plates, Process}; /// Pixels converted per call into the CMYK profile, to bound the scratch buffers. const CHUNK: usize = 16 * 1024; -fn on(enabled: &[bool], index: usize) -> bool { +pub(crate) fn on(enabled: &[bool], index: usize) -> bool { enabled.get(index).copied().unwrap_or(true) } @@ -32,25 +32,29 @@ fn to_srgb(cms: &dyn CmykConversion, cmyk: &[u8], rgb: &mut [u8]) { pub fn appearance(alternate: Alternate, cms: &dyn CmykConversion) -> Option> { let mut rgb = vec![0_u8; 256 * 3]; match alternate { - Alternate::Lab([l, a, b]) => { - let lab: Vec = (0..=255_u8) - .flat_map(|t| { - let t = f32::from(t) / 255.0; - [100.0 + t * (l - 100.0), t * a, t * b] - }) - .collect(); - lab_to_srgb8(&lab, &mut rgb)?; - } - Alternate::Cmyk(solid) => { - let cmyk: Vec = (0..=255_u8) - .flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)) - .collect(); - to_srgb(cms, &cmyk, &mut rgb); - } + Alternate::Lab(lab) => lab_to_srgb8(&lab_ramp(lab), &mut rgb)?, + Alternate::Cmyk(solid) => to_srgb(cms, &cmyk_ramp(solid), &mut rgb), } Some(rgb.as_chunks::<3>().0.to_vec()) } +/// A Lab spot alternate at each tint byte, three values per tint, from paper white (L\* 100) +/// to the solid in a straight line. +pub(crate) fn lab_ramp([l, a, b]: [f32; 3]) -> Vec { + (0..=255_u8) + .flat_map(|t| { + let t = f32::from(t) / 255.0; + [100.0 + t * (l - 100.0), t * a, t * b] + }) + .collect() +} + +/// A CMYK spot alternate at each tint byte, four bytes per tint, its components scaled by the +/// tint. +pub(crate) fn cmyk_ramp(solid: [f32; 4]) -> Vec { + (0..=255_u8).flat_map(|t| solid.map(|c| (f32::from(t) * if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }).round() as u8)).collect() +} + impl Plates { /// The enabled inks as they print, as sRGB8 (three bytes per pixel, row-major, /// `width × height` pixels). `enabled` is indexed by [`InkId::index`]; inks past its end diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index ca4598c95..5b8967a89 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -11,10 +11,12 @@ pub mod coverage; pub mod flatten; pub mod ink; pub mod marks; +pub mod output; pub mod plates; pub use cms::{CmsConversion, ProfileError}; pub use coverage::{Above, Coverage, InkStats, MAX_REGIONS, Region, Stats}; pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name, to_pdf_color}; pub use marks::{MarkOptions, Marks, MarksError}; +pub use output::{Output, OutputError}; pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/prepress/src/output.rs b/crates/prepress/src/output.rs new file mode 100644 index 000000000..9bc9a0296 --- /dev/null +++ b/crates/prepress/src/output.rs @@ -0,0 +1,211 @@ +//! The plates as they print, in a chosen output profile: process inks convert from the press's +//! CMYK straight to the output (CMYK → PCS → output, not through sRGB), and spot inks combine +//! in the output space. + +use std::sync::Arc; + +use craft_cms::{Builtin, ColorSpace, Intent, Profile, Transform, TransformOptions, cached}; + +use crate::cms::encode_lab; +use crate::composite::{cmyk_ramp, lab_ramp, on}; +use crate::coverage::filled; +use crate::{Alternate, InkId, Plates, Process}; + +/// Pixels converted per call, to bound the scratch buffer. +const CHUNK: usize = 16 * 1024; + +/// Why an [`Output`] can't be built. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum OutputError { + #[error("the press profile is not CMYK")] + PressNotCmyk, + #[error("the output profile is not gray, RGB or CMYK")] + UnsupportedOutput, + #[error("craft-cms can't convert into the output profile: {0}")] + Transform(String), +} + +/// A conversion from a press (the CMYK printing condition the plates were made for) to an +/// output profile, such as a printer's. Built once, used for every composite. +pub struct Output { + process: Arc, + from_lab: Arc, + channels: usize, + subtractive: bool, +} + +impl Output { + /// Press CMYK into `output` (gray, RGB or CMYK) at `intent`, with black point compensation + /// when `bpc` (craft-cms ignores it for absolute colorimetric). Relative colorimetric with + /// `bpc` maps the press's paper and black onto the output's; absolute colorimetric keeps + /// the press paper's tint. + pub fn new(press: &Profile, output: &Profile, intent: Intent, bpc: bool) -> Result { + if press.color_space != ColorSpace::Cmyk { + return Err(OutputError::PressNotCmyk); + } + let subtractive = match output.color_space { + ColorSpace::Gray | ColorSpace::Rgb => false, + ColorSpace::Cmyk => true, + _ => return Err(OutputError::UnsupportedOutput), + }; + // The fast path's tables: a composite converts every pixel of a page. + let opts = TransformOptions { intent, bpc, precise_float: false }; + let failed = |e: craft_cms::CmsError| OutputError::Transform(format!("{e:?}")); + let process = cached(press, output, opts).map_err(failed)?; + let from_lab = cached(Builtin::LabD50.profile(), output, opts).map_err(failed)?; + Ok(Self { process, from_lab, channels: output.channels(), subtractive }) + } + + /// Bytes per pixel of the output: 1 gray, 3 RGB, 4 CMYK. + pub fn channels(&self) -> usize { + self.channels + } + + /// The spot's appearance alone at each tint byte, `channels` bytes per tint (see + /// [`appearance`](crate::composite::appearance)); `None` when it can't convert. + fn ramp(&self, alternate: Alternate) -> Option> { + let mut out = filled(256 * self.channels, 0_u8)?; + match alternate { + Alternate::Lab(lab) => { + let encoded: Vec = lab_ramp(lab).as_chunks::<3>().0.iter().flat_map(|c| encode_lab(*c)).collect(); + let mut values = filled(out.len(), 0.0_f32)?; + self.from_lab.convert_f32(&encoded, 3, &mut values, self.channels, false).ok()?; + for (byte, v) in out.iter_mut().zip(values) { + *byte = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + } + } + Alternate::Cmyk(solid) => self.process.convert_u8(&cmyk_ramp(solid), 4, &mut out, self.channels, false).ok()?, + } + Some(out) + } + + /// `ink` printed over `px`: a multiply of an additive output's values, and of a + /// subtractive one's complements (ISO 32000-2 §11.3.4), so inks add. + fn combine(&self, px: &mut [u8], ink: &[u8]) { + for (c, s) in px.iter_mut().zip(ink) { + let (c16, s16) = (u16::from(*c), u16::from(*s)); + *c = if self.subtractive { (255 - ((255 - c16) * (255 - s16) + 127) / 255) as u8 } else { ((c16 * s16 + 127) / 255) as u8 }; + } + } +} + +impl Plates { + /// The enabled inks as they print, in `output`'s space ([`Output::channels`] bytes per + /// pixel, row-major, `width × height` pixels). `enabled` is as for + /// [`Plates::composite`]. + /// + /// The process plates convert from the press profile `output` was built with, directly. + /// Each spot plate then combines over them with the spot's appearance at its tint, from its + /// alternate, converted into the output; spots whose alternate can't convert are left out. + /// The overprint decisions are the plates'. Empty when the buffer overflows, can't be + /// allocated, or craft-cms can't convert the process inks. + pub fn composite_to(&self, enabled: &[bool], output: &Output) -> Vec { + let n = output.channels; + let Some(mut out) = self.pixels().and_then(|p| p.checked_mul(n)).and_then(|len| filled(len, 0_u8)) else { + return Vec::new(); + }; + let process: Vec> = Process::ALL + .iter() + .map(|p| { + let id = InkId::process(*p); + self.plane(id).filter(|_| on(enabled, id.index())) + }) + .collect(); + let mut cmyk = Vec::with_capacity(CHUNK * 4); + for (c, chunk) in out.chunks_mut(CHUNK * n).enumerate() { + let start = c * CHUNK; + cmyk.clear(); + for i in start..start + chunk.len() / n { + cmyk.extend(process.iter().map(|plane| plane.and_then(|p| p.get(i)).copied().unwrap_or(0))); + } + if output.process.convert_u8(&cmyk, 4, chunk, n, false).is_err() { + return Vec::new(); + } + } + + for (id, spot) in self.inks.spots().filter(|(id, _)| on(enabled, id.index())) { + let Some(plane) = self.plane(id).filter(|p| p.iter().any(|t| *t > 0)) else { continue }; + let Some(ramp) = output.ramp(spot.alternate) else { continue }; + for (px, tint) in out.chunks_exact_mut(n).zip(plane) { + let at = usize::from(*tint) * n; + if let Some(ink) = ramp.get(at..at + n).filter(|_| *tint > 0) { + output.combine(px, ink); + } + } + } + out + } +} + +#[cfg(test)] +mod tests { + use craft_cms::{Builtin, Intent, builtin}; + + use super::{Output, OutputError}; + use crate::cms::srgb8_to_lab; + use crate::coverage::tests::synthetic; + use crate::{Alternate, CmsConversion, Conversion, Ink, InkSet, Plates, Spot}; + + fn fogra39() -> &'static craft_cms::Profile { + Builtin::CoatedFogra39.profile() + } + + fn near(got: &[u8], expected: &[u8], tolerance: u8) -> bool { + got.len() == expected.len() && got.iter().zip(expected).all(|(g, e)| g.abs_diff(*e) <= tolerance) + } + + #[test] + fn a_cmyk_output_of_the_press_profile_keeps_the_press_inks() { + // Cyan, red and a light blue come back within the profile's own round trip. Through + // sRGB, cyan is out of gamut and comes back as [201, 59, 0, 0]. (Black is left out: the + // output's BToA tables generate their own black, as they would for a printer.) + let p = synthetic(3, 1, [70.0, 5.0, 60.0], [vec![255, 0, 64], vec![0, 255, 32], vec![0, 255, 0], vec![0; 3], vec![0; 3]]); + let output = Output::new(fogra39(), fogra39(), Intent::RelativeColorimetric, false).unwrap(); + assert_eq!(output.channels(), 4); + let cmyk = p.composite_to(&[], &output); + assert!(near(&cmyk[0..4], &[255, 0, 0, 0], 12), "cyan: {:?}", &cmyk[0..4]); + assert!(near(&cmyk[4..8], &[0, 255, 255, 0], 12), "red: {:?}", &cmyk[4..8]); + assert!(near(&cmyk[8..12], &[64, 32, 0, 0], 12), "light blue: {:?}", &cmyk[8..12]); + } + + #[test] + fn an_srgb_output_matches_the_preview_composite() { + const GOLD: [u8; 3] = [212, 175, 55]; + let lab = srgb8_to_lab(GOLD).unwrap(); + // Black alone, Gold alone, Gold over black, Gold at 50%. + let p = synthetic(4, 1, lab, [vec![0; 4], vec![0; 4], vec![0; 4], vec![255, 0, 255, 0], vec![0, 255, 255, 128]]); + let output = Output::new(fogra39(), builtin::srgb(), Intent::RelativeColorimetric, true).unwrap(); + let rgb = p.composite_to(&[], &output); + let preview = p.composite(&[], &CmsConversion::fogra39()); + assert!(near(&rgb, &preview, 2), "{rgb:?} {preview:?}"); + assert!(near(&p.composite_to(&[true, true, true, false], &output)[0..3], &[255, 255, 255], 1), "black hidden"); + } + + #[test] + fn a_spot_adds_its_ink_in_a_cmyk_output() { + let mut inks = InkSet::new(); + inks.add(Ink::Spot(Spot { name: "Rubine".into(), alternate: Alternate::Cmyk([0.0, 1.0, 0.0, 0.0]), solidity: None })); + // Cyan with the spot over it, and the spot alone. + let planes = vec![vec![255, 0], vec![0; 2], vec![0; 2], vec![0; 2], vec![255, 255]]; + let p = Plates { inks, unseparated: Vec::new(), width: 2, height: 1, dpi: 72.0, conversion: Conversion::DeviceFormulas, planes }; + let output = Output::new(fogra39(), fogra39(), Intent::RelativeColorimetric, false).unwrap(); + let cmyk = p.composite_to(&[], &output); + // Magenta's round trip through the profile adds a little yellow. + assert!(near(&cmyk[0..4], &[255, 255, 8, 0], 8), "spot over cyan: {:?}", &cmyk[0..4]); + assert!(near(&cmyk[4..8], &[0, 255, 8, 0], 8), "spot alone: {:?}", &cmyk[4..8]); + assert!(near(&p.composite_to(&[true, true, true, true, false], &output)[0..4], &[255, 0, 0, 0], 6), "spot hidden"); + } + + #[test] + fn outputs_need_a_cmyk_press_and_a_gray_rgb_or_cmyk_output() { + let lab = Builtin::LabD50.profile(); + let srgb = builtin::srgb(); + assert_eq!(Output::new(srgb, srgb, Intent::RelativeColorimetric, true).err(), Some(OutputError::PressNotCmyk)); + assert_eq!(Output::new(fogra39(), lab, Intent::RelativeColorimetric, true).err(), Some(OutputError::UnsupportedOutput)); + let gray = Output::new(fogra39(), builtin::linear_gray(), Intent::RelativeColorimetric, true).unwrap(); + let p = synthetic(2, 1, [70.0, 5.0, 60.0], [vec![0; 2], vec![0; 2], vec![0; 2], vec![0, 255], vec![0; 2]]); + let g = p.composite_to(&[], &gray); + assert!(g.len() == 2 && g[0] > 250 && g[1] < 10, "paper and black in gray: {g:?}"); + } +}