prepress: composite the plates into a chosen output profile

Plates::composite converts through sRGB, which clips the press gamut
before a print profile sees it. Output converts press CMYK straight into
a gray, RGB or CMYK output profile (CMYK -> PCS -> output) at a chosen
intent and black point compensation, and Plates::composite_to combines
spot inks in that space: a multiply in gray or RGB, a multiply of the
complements in CMYK. CmsConversion::profile exposes the press profile.

The spot ramps move into shared helpers, used by composite and output;
the Lab encoding moves into one used by cms and output.

Part of #838 (item 2). The printer-leg semantics are adapted from pdfcer
(MIT, same author), crates/pdfcer-print/src/icc_proof.rs at 0a249a73;
the code is new, on craft-cms.

---------

Co-authored-by: KenM76 <KenM76>
This commit is contained in:
Ken MantleandKenM76 authored and GitHub committed 2026-11-12 10:05:44 -05:00
1 parent 47b6bbadf0
commit 35b4918481
5 files changed
+273 -27

No files matched your search

+28
View File
@@ -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:
+12 -11
View File
@@ -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<f32> = 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<f32> = 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<f32> = 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<f32> = 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) {
+20 -16
View File
@@ -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<Vec<[u8; 3]>> {
let mut rgb = vec![0_u8; 256 * 3];
match alternate {
Alternate::Lab([l, a, b]) => {
let lab: Vec<f32> = (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<u8> = (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<f32> {
(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<u8> {
(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
+2
View File
@@ -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};
+211
View File
@@ -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<Transform>,
from_lab: Arc<Transform>,
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<Self, OutputError> {
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<Vec<u8>> {
let mut out = filled(256 * self.channels, 0_u8)?;
match alternate {
Alternate::Lab(lab) => {
let encoded: Vec<f32> = 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<u8> {
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<Option<&[u8]>> = 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:?}");
}
}