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.
This commit is contained in:
aiongg committed 2026-10-20 03:57:10 +00:00
1 parent 4026931196
commit e6d2a24641
5 files changed
+208 -8

No files matched your search

+1 -1
View File
@@ -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 }
+34 -6
View File
@@ -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
+81
View File
@@ -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<PdfColor> {
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<InkId, f32> = 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::<Vec<_>>()[..] {
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 {
+1 -1
View File
@@ -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};
+91
View File
@@ -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<craft_pdf::write::backend::color::Color>) -> Vec<u8> {
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<String>)> {
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<String>)> = spaces
.keys()
.map(|key| {
let array = spaces.get::<Array<'_>>(key.as_ref()).unwrap();
let items: Vec<Object<'_>> = array.iter::<Object<'_>>().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::<Name<'_>>().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::<Vec<_>>();
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<u8> { 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");
}