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>
This commit is contained in:
aionggandGuillem Hermida committed 2026-10-20 03:15:36 +00:00
1 parent b01d19c716
commit c025c0dfff
9 files changed
+988

No files matched your search

Generated
+9
View File
@@ -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"
+1
View File
@@ -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 }
+16
View File
@@ -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 }
+79
View File
@@ -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.
+143
View File
@@ -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<Option<Arc<Transform>>>,
from_lab: OnceLock<Option<Arc<Transform>>>,
to_srgb: OnceLock<Option<Arc<Transform>>>,
}
/// 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, ProfileError> {
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<Self, ProfileError> {
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<Option<Arc<Transform>>>, src: &Profile, dst: &Profile, bpc: bool) -> Option<Arc<Transform>> {
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<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 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<Option<Arc<Transform>>> = 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])
}
+261
View File
@@ -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> {
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<f32>,
}
/// 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<Ink>,
}
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<InkId> {
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<InkId> {
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<Item = (InkId, &Ink)> {
// 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<Item = (InkId, &Spot)> {
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"), "€");
}
}
+12
View File
@@ -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};
+320
View File
@@ -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<String>,
},
/// 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<String>,
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<Vec<u8>>,
}
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<u8> {
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<Plates, Error> {
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> = 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<raster::Raster, Error> {
// 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<CmykSimulation>, 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<PdfInk>,
}
fn spot_sink(log: &Arc<Mutex<SpotLog>>) -> 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<String> {
let s = dict.get::<PdfText<'_>>(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<u16> = 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<Array<'a>>) -> Vec<Dict<'a>> {
intents.iter().flat_map(|a| a.iter::<Dict<'a>>()).take(MAX_OUTPUT_INTENTS).collect()
}
let xref = pdf.xref();
let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::<Array<'_>>(OUTPUT_INTENTS)));
if listed.is_empty() {
listed = list(xref.get::<Dict<'_>>(xref.root_id()).and_then(|catalog| catalog.get::<Array<'_>>(OUTPUT_INTENTS)));
}
// A stable sort: the PDF/X intent first, the others in their order.
listed.sort_by_key(|d| d.get::<Name<'_>>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX"));
listed.iter().find_map(|intent| {
let icc = intent.get::<Stream<'_>>(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);
}
}
+147
View File
@@ -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<u8>]) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8>| 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");
}