From e6d2a24641950ee90caec438cbb8d1a107baace1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:57:10 +0000 Subject: [PATCH] prepress: to_pdf_color writes ink colors as DeviceCMYK, Separation or DeviceN Process tints become DeviceCMYK, one spot a Separation with its alternate, other mixes a DeviceN space with process inks first, registration and None the All and None separations. Lab spot alternates convert through the printing condition, as resolve does. --- crates/prepress/Cargo.toml | 2 +- crates/prepress/README.md | 40 ++++++++++++--- crates/prepress/src/ink.rs | 81 +++++++++++++++++++++++++++++ crates/prepress/src/lib.rs | 2 +- crates/prepress/tests/plates.rs | 91 +++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 8 deletions(-) diff --git a/crates/prepress/Cargo.toml b/crates/prepress/Cargo.toml index 3e65e8b9..d27432ff 100644 --- a/crates/prepress/Cargo.toml +++ b/crates/prepress/Cargo.toml @@ -11,6 +11,6 @@ rust-version.workspace = true workspace = true [dependencies] -craft-pdf = { workspace = true, features = ["raster"] } +craft-pdf = { workspace = true, features = ["raster", "write"] } craft-cms = { workspace = true } thiserror = { workspace = true } diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 3c0dcd6b..6ebc7644 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -6,7 +6,8 @@ Prepress for the Craft apps: separations, ink limits and the ink manager. (always first, in plate order) and the spot inks of a job, and is the job's ink manager (aliases, spots to process); `InkColor` is a color 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. + PDF names: `/Gold#20Foil` and `Gold Foil` are one ink. `to_pdf_color` writes an `InkColor` + as a krilla color for export. - `plates`: one 8-bit plane per ink (255 = 100% ink) rendered from a PDF page. - `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. @@ -131,15 +132,42 @@ alternate needs the printing condition to become CMYK. Apply it in two places: -- **Export.** The writer calls `resolve` on every color it emits, so the PDF carries the - result: DeviceCMYK when `as_cmyk` is `Some`, else a `Separation` or `DeviceN` of the - resolved inks (the aliased spot's name, not the original). An overprinting color that - became CMYK is written with `OPM 1`, so its zero components leave the plates under it as - they are. +- **Export.** The writer calls `resolve` on every color it emits, then + `to_pdf_color(&resolved, &inks, &cms)` for the krilla color to paint with (see + [Export](#export)). An overprinting color that became CMYK is written with `OPM 1`, so its + zero components leave the plates under it as they are. - **Preview.** The preview separates that exported PDF with `plates` and shows `composite` and the coverage readings. Plates are the truth: an ink manager change shows after the next export, and the preview never recomputes overprint from the document. +## Export + +`to_pdf_color(&color, &inks, &cms)` gives the krilla color (`craft_pdf::write::backend::color::Color`) +for an `InkColor`, choosing the color space from the inks it names: + +| Color | Written as | +|---|---| +| Tints of process inks only | DeviceCMYK; unlisted process inks are 0 | +| One spot ink | `Separation` with the spot's alternate as DeviceCMYK fallback | +| Any other mix | `DeviceN`: process inks first in plate order, then spots in `InkSet` order | +| `All` | `Separation /All` (registration) | +| `None` | `Separation /None` (marks no plate) | + +A spot's alternate is `spot_cmyk`: a CMYK alternate as given, a Lab one converted through +`cms` into the printing condition, so a spot has one alternate in every space that names it. +`None` when the color names an ink not in the set or a `DeviceN` space would need more than +32 colorants. + +```rust +use craft_prepress::{CmsConversion, InkColor, to_pdf_color}; + +let cms = CmsConversion::fogra39(); +let printed = inks.resolve(&InkColor::Tints(vec![(gold, 1.0), (black, 0.5)]), &cms); +if let Some(color) = to_pdf_color(&printed, &inks, &cms) { + surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); // DeviceN [Black Gold] +} +``` + ## In DesignCraft and VectorCraft - **Separations Preview**: export the spread to PDF diff --git a/crates/prepress/src/ink.rs b/crates/prepress/src/ink.rs index b023b1cf..d507e1a6 100644 --- a/crates/prepress/src/ink.rs +++ b/crates/prepress/src/ink.rs @@ -5,6 +5,9 @@ use std::collections::{BTreeMap, BTreeSet}; use craft_cms::math; use craft_pdf::interpret::color::CmykConversion; +use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace}; +use craft_pdf::write::backend::color::separation::{self, SeparationColorant, SeparationSpace}; +use craft_pdf::write::backend::color::{Color as PdfColor, RegularColor, cmyk}; use crate::cms::lab_to_srgb8; @@ -365,6 +368,84 @@ impl InkColor { } } +/// `color` as a krilla fill or stroke color, for the PDF writer. Pass the color as it prints, +/// after [`InkSet::resolve`]. +/// +/// - Tints of process inks only: DeviceCMYK, unlisted inks 0 (so with `OPM 1` an overprinting +/// paint leaves their plates as they are). +/// - One spot ink alone: a `Separation` space with the spot's alternate as DeviceCMYK fallback. +/// - Any other mix: a `DeviceN` space, process inks first in plate order, then spot inks in +/// set order. Each spot keeps the alternate its `Separation` space has. +/// - `All`: the registration color, a `Separation /All` space; `None`: a `Separation /None` +/// space, which marks no plate. +/// +/// Spot alternates are [`InkSet::spot_cmyk`]: a CMYK alternate as given, a Lab one through +/// `cms` into the printing condition. A tint listed twice combines as overlapping halftones, +/// as in `resolve`. `None` when a tint's ink is not in `inks`, a spot's Lab alternate can't be +/// converted, a spot name is empty, or a `DeviceN` space would have more than 32 colorants. +pub fn to_pdf_color(color: &InkColor, inks: &InkSet, cms: &dyn CmykConversion) -> Option { + let tints = match color { + InkColor::All(tint) => return Some(separation(SeparationColorant::AllColorants, *tint, cmyk::Color::new(255, 255, 255, 255))), + InkColor::None => return Some(separation(SeparationColorant::NoColorant, 0.0, cmyk::Color::new(0, 0, 0, 0))), + InkColor::Tints(tints) => tints, + }; + // Sorted by id: process inks first, in plate order, then spots in set order. + let mut merged: BTreeMap = BTreeMap::new(); + for &(id, tint) in tints { + inks.get(id)?; + let t = merged.entry(id).or_insert(0.0); + *t = 1.0 - (1.0 - *t) * (1.0 - unit(tint)); + } + if merged.keys().all(|id| id.index() < Process::ALL.len()) { + let mut cmyk = [0_u8; 4]; + for (id, tint) in &merged { + if let Some(c) = cmyk.get_mut(id.index()) { + *c = byte(*tint); + } + } + let [c, m, y, k] = cmyk; + return Some(RegularColor::Cmyk(cmyk::Color::new(c, m, y, k)).into()); + } + let spot = |id: InkId| -> Option<(String, cmyk::Color)> { + let name = inks.get(id)?.name(); + let [c, m, y, k] = inks.spot_cmyk(id, cms)?.map(byte); + (!name.is_empty()).then(|| (name.to_owned(), cmyk::Color::new(c, m, y, k))) + }; + if let [(&id, &tint)] = merged.iter().collect::>()[..] { + let (name, alternate) = spot(id)?; + return Some(separation(SeparationColorant::Custom(name), tint, alternate)); + } + let mut colorants = Vec::with_capacity(merged.len()); + for &id in merged.keys() { + colorants.push(match Process::ALL.get(id.index()) { + Some(p) => Colorant::Process(match p { + Process::Cyan => device_n::Process::Cyan, + Process::Magenta => device_n::Process::Magenta, + Process::Yellow => device_n::Process::Yellow, + Process::Black => device_n::Process::Black, + }), + None => { + let (name, alternate) = spot(id)?; + Colorant::Spot { name, alternate } + } + }); + } + let space = DeviceNSpace::new(colorants)?; + Some(device_n::Color::new(merged.values().map(|t| byte(*t)).collect(), space)?.into()) +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +fn byte(v: f32) -> u8 { + (unit(v) * 255.0).round() as u8 +} + +fn separation(colorant: SeparationColorant, tint: f32, fallback: cmyk::Color) -> PdfColor { + separation::Color::new(byte(tint), SeparationSpace::new(colorant, RegularColor::Cmyk(fallback))).into() +} + /// Overprint parameters of a paint (ISO 32000-2 §8.6.7). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Overprint { diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 3543f633..24a7d941 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -12,5 +12,5 @@ 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}; +pub use ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot, normalize_name, to_pdf_color}; pub use plates::{Conversion, Error, Fallback, MAX_PLATE_PIXELS, MAX_SPOT_INKS, Options, Plates, plates}; diff --git a/crates/prepress/tests/plates.rs b/crates/prepress/tests/plates.rs index 2b27dadf..54197a28 100644 --- a/crates/prepress/tests/plates.rs +++ b/crates/prepress/tests/plates.rs @@ -158,3 +158,94 @@ fn lab_colors_convert_through_the_profile_and_lab_white_is_paper() { 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"); } + +/// A 60 × 10 pt page written by the PDF writer, with a 10 pt wide fill of each color. +fn written(colors: Vec) -> Vec { + use craft_pdf::write::backend::{geom, page::PageSettings, paint::Fill}; + let mut writer = craft_pdf::write::Writer::new(Default::default(), Default::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(geom::Size::from_wh(60.0, 10.0).unwrap())); + let mut surface = page.surface(); + for (i, color) in colors.into_iter().enumerate() { + let mut path = geom::PathBuilder::new(); + path.push_rect(geom::Rect::from_xywh(10.0 * i as f32, 0.0, 10.0, 10.0).unwrap()); + surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); + surface.draw_path(&path.finish().unwrap()); + } + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// The page's color space resources as (family, colorant names), sorted. +fn color_spaces(pdf: &[u8]) -> Vec<(String, Vec)> { + use craft_pdf::syntax::object::{Array, Name, Object}; + let pdf = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let page = pdf.pages().iter().next().unwrap(); + let spaces = &page.resources().color_spaces; + let mut out: Vec<(String, Vec)> = spaces + .keys() + .map(|key| { + let array = spaces.get::>(key.as_ref()).unwrap(); + let items: Vec> = array.iter::>().collect(); + let Some(Object::Name(family)) = items.first() else { panic!("a color space without a family") }; + let names = match items.get(1) { + Some(Object::Name(n)) => vec![n.as_str().to_owned()], + Some(Object::Array(a)) => a.iter::>().map(|n| n.as_str().to_owned()).collect(), + _ => Vec::new(), + }; + (family.as_str().to_owned(), names) + }) + .collect(); + out.sort(); + out +} + +#[test] +fn ink_colors_write_as_cmyk_separation_or_device_n_and_separate_onto_their_plates() { + use craft_prepress::{InkColor, InkSet, Spot, to_pdf_color}; + let mut inks = InkSet::new(); + let gold = inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap(); + let teal = inks.add(Ink::Spot(Spot { name: "Teal".into(), alternate: Alternate::Lab([55.0, -40.0, -10.0]), solidity: None })).unwrap(); + let (c, k) = (InkId::process(Process::Cyan), black()); + let cms = CmsConversion::fogra39(); + let colors = [ + InkColor::Tints(vec![(k, 0.4), (c, 0.6)]), + InkColor::Tints(vec![(gold, 0.5)]), + InkColor::Tints(vec![(gold, 1.0), (k, 0.5)]), + InkColor::Tints(vec![(teal, 1.0)]), + InkColor::All(1.0), + InkColor::None, + ]; + let pdf = written(colors.iter().map(|color| to_pdf_color(color, &inks, &cms).unwrap()).collect()); + let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::>(); + let mut expected = vec![ + ("DeviceN".to_owned(), names(&["Black", "Gold"])), + ("Separation".to_owned(), names(&["All"])), + ("Separation".to_owned(), names(&["Gold"])), + ("Separation".to_owned(), names(&["None"])), + ("Separation".to_owned(), names(&["Teal"])), + ]; + expected.sort(); + assert_eq!(color_spaces(&pdf), expected, "process only is DeviceCMYK, which needs no resource"); + + let p = plates(&pdf, 0, 72.0, &Options::default()).unwrap(); + let names: Vec<&str> = p.inks.iter().map(|(_, ink)| ink.name()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Gold", "Teal"]); + let (gold, teal) = (p.inks.lookup("Gold").unwrap(), p.inks.lookup("Teal").unwrap()); + let at = |x: u32| -> Vec { p.inks.iter().map(|(id, _)| p.tint(id, x, 5).unwrap()).collect() }; + assert_eq!(at(5), [153, 0, 0, 102, 0, 0]); + assert_eq!(at(15), [0, 0, 0, 0, 128, 0]); + assert_eq!(at(25), [0, 0, 0, 128, 255, 0]); + assert_eq!(at(35), [0, 0, 0, 0, 0, 255]); + assert_eq!(at(45), [255; 6], "registration"); + assert_eq!(at(55), [0; 6]); + assert_eq!((p.tint(gold, 25, 5), p.tint(teal, 35, 5)), (Some(255), Some(255))); + + let unknown = InkSet::new(); + assert_eq!(to_pdf_color(&colors[1], &unknown, &cms), None, "an ink not in the set"); + let mut many = InkSet::new(); + let tints: Vec<(InkId, f32)> = (0..33) + .map(|i| (many.add(Ink::Spot(Spot { name: format!("S{i}"), alternate: Alternate::Cmyk([0.0; 4]), solidity: None })).unwrap(), 1.0)) + .collect(); + assert_eq!(to_pdf_color(&InkColor::Tints(tints), &many, &cms), None, "more than 32 colorants"); +}