diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs new file mode 100644 index 00000000..8235dc4d --- /dev/null +++ b/crates/doc/src/inks.rs @@ -0,0 +1,157 @@ +//! Printing inks: the plates of a document (four process inks and its spot inks), separating a +//! colour into them ([`inks`]), and visiting every colour of the art with its swatch link (the +//! Separations Preview recolours the art with these; print separates it into plates). + +use std::sync::Arc; + +use vectorcraft_color::cms::{Cms, Intent, PROCESS_PLATES}; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Paint}; + +use crate::{AppearanceItem, Document, Node, NodeKind}; + +/// Ink coverage of one paint colour. +#[derive(Clone, Debug, PartialEq)] +pub struct Inks { + /// Process inks C, M, Y, K (0..1). + pub cmyk: [f32; 4], + /// Spot ink (swatch name, tint 0..1). + pub spot: Option<(String, f32)>, + /// Registration: the tint (every process ink's) prints on every spot plate too. + pub registration: bool, +} + +impl Inks { + /// The tint this colour prints on spot plate `name`. + pub fn spot_tint(&self, name: &str) -> f32 { + match &self.spot { + _ if self.registration => self.cmyk[0], + Some((n, t)) if n == name => *t, + _ => 0.0, + } + } + + /// The ink this colour prints on `plate` (a process plate name or a spot swatch name). + pub fn on_plate(&self, plate: &str) -> f32 { + match PROCESS_PLATES.iter().position(|p| *p == plate) { + Some(i) => self.cmyk[i], + None => self.spot_tint(plate), + } + } +} + +/// A printing plate. +#[derive(Clone, Debug, PartialEq)] +pub struct Plate { + pub name: String, + pub spot: bool, + /// Display colour of the ink (for the panel swatch). + pub rgb: [f32; 3], +} + +/// The plates of `doc`: the four process plates plus one per spot swatch. +pub fn plates(doc: &Document) -> Vec { + let c = vectorcraft_color::cms::active(); + let mut v: Vec = PROCESS_PLATES + .iter() + .enumerate() + .map(|(i, n)| { + let mut ink = [0.0; 4]; + ink[i] = 1.0; + Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) } + }) + .collect(); + for sw in doc.swatches_iter() { + if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint) + && !v.iter().any(|p| p.name == sw.name) + { + v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(&doc.linked_color(*color, true)) }); + } + } + v +} + +/// The ink colour of spot swatch `name` as it shows ([`Document::linked_color`]). +pub fn spot_color(doc: &Document, name: &str) -> Option { + let s = doc.swatch(name).filter(|s| s.spot)?; + s.paint.color().map(|c| doc.linked_color(c, true)) +} + +/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. +pub type Link<'a> = Option<(&'a str, f32)>; + +/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at +/// its tint; one linked to the Registration swatch prints its tint on every plate. +pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { + if let Some((REGISTRATION, tint)) = link { + return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; + } + if let Some((name, tint)) = link + && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) + { + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; + } + Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } +} + +/// Visits one colour: the colour, its swatch link and its tint, all of which it may change. +pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) + 'a; + +fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) { + match p { + Paint::Solid { color, swatch, tint } => f(color, swatch, tint), + Paint::Gradient(g) => { + for s in &mut g.gradient.stops { + f(&mut s.color, &mut s.swatch, &mut s.tint); + } + } + _ => {} + } +} + +/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not +/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are +/// dropped. +pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) { + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) => visit_paint(&mut l.paint, f), + AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f), + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + visit_paint(&mut r.style.fill, f); + visit_paint(&mut r.style.stroke, f); + } + } + NodeKind::Mesh(m) => { + for p in &mut m.points { + f(&mut p.color, &mut None, &mut 1.0); + } + } + _ => {} + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + visit_node_colors(Arc::make_mut(c), f); + } + } +} + +/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh +/// points) given its swatch link and tint, keeping the links. +pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { + visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint)))); +} + +/// Apply `f` to every colour in the document's art and symbol definitions. +pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) { + for l in &mut doc.layers { + map_node_colors(Arc::make_mut(l), f); + } + for s in &mut doc.symbols { + map_node_colors(Arc::make_mut(&mut s.art), f); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3e7ac165..24713339 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod graph; pub mod hit; +pub mod inks; pub mod links; pub mod live; pub mod marks; @@ -17,6 +18,7 @@ pub mod overprint; pub mod pattern; mod pixels; pub mod profiles; +pub mod range; pub mod rastersettings; mod reach; pub mod selection; @@ -520,6 +522,10 @@ pub struct Document { /// them (on, the default); off, auto slices cover the art and the slices. #[serde(default = "yes", skip_serializing_if = "skip::is_true")] pub slices_clip_to_artboard: bool, + /// File → Print: the print settings saved with the document (`vectorcraft_pdf::PrintSettings` + /// as JSON: the print engine sits above this crate); `None` until they are set up. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub print_setup: Option, } fn ppi72() -> f64 { @@ -585,6 +591,7 @@ impl Document { extra: Default::default(), slices: vec![], slices_clip_to_artboard: true, + print_setup: None, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 86b61921..4f65e883 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -2,7 +2,9 @@ //! over the inks below instead of knocking them out ([`crate::FillLayer::overprint`]). //! Edit → Edit Colors → Overprint Black sets it on black fills and strokes ([`OverprintBlack`]). -use vectorcraft_color::{Color, Paint}; +use std::sync::Arc; + +use vectorcraft_color::{BlendMode, Color, Paint}; use crate::{Document, Node, NodeId, NodeKind}; @@ -92,6 +94,40 @@ impl Node { } } +/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend +/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a +/// printed plate this approximates their inks printing over the inks below. With `discard_white` +/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead. +pub fn multiply_overprints(a: &mut Arc, discard_white: bool) { + if !a.has_overprint() { + return; + } + let n = Arc::make_mut(a); + let multiplies = + |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); + for it in &mut n.appearance.items { + match it { + crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, + crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, + _ => {} + } + } + for c in n.children_mut().into_iter().flatten() { + multiply_overprints(c, discard_white); + } +} + +/// A solid paint of paper white (no ink in any colour model). +fn is_white(p: &Paint) -> bool { + let Paint::Solid { color, .. } = p else { return false }; + match *color { + Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0, + Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0, + Color::Gray { k } => k <= 0.0, + Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0, + } +} + impl Document { /// The names of the spot swatches. pub fn spot_names(&self) -> Vec { diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs new file mode 100644 index 00000000..80b4d6d3 --- /dev/null +++ b/crates/doc/src/range.rs @@ -0,0 +1,37 @@ +//! Page ranges as people type them (`"1-3, 5"`): artboards to export or print, PDF pages to +//! open, tiles to print. + +/// A 1-based page/artboard range such as `"1-3, 5"` → 0-based indices in the order given (repeats +/// dropped). Each number must lie in `1..=count`; `"3-"` runs to the last one, `"-2"` from the first. +pub fn parse_range(s: &str, count: usize) -> Result, String> { + let num = |t: &str, open: usize| -> Result { + let t = t.trim(); + if t.is_empty() { + return Ok(open); + } + t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) + }; + let mut out = vec![]; + for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { + let (a, b) = match part.replace('\u{2013}', "-").split_once('-') { + Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")), + Some((a, b)) => (num(a, 1)?, num(b, count)?), + None => { + let n = num(part, 0)?; + (n, n) + } + }; + if a > b { + return Err(format!("range `{s}`: `{part}` runs backwards")); + } + for i in a - 1..b { + if !out.contains(&i) { + out.push(i); + } + } + } + if out.is_empty() { + return Err(format!("range `{s}` is empty")); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 482730c1..1e188a1b 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -662,7 +662,7 @@ fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String { } /// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`. -fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result { +pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result { let out = match path { Some(path) => { write_file(path, bytes)?; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 83e6177d..f07c1723 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -109,7 +109,7 @@ pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result { for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { @@ -121,7 +121,7 @@ fn merge(base: &mut Value, over: &Value) { } /// A writer error as an engine error: bad settings are bad params of `cmd`. -fn pdf_error(cmd: &str, e: PdfError) -> EngineError { +pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError { match e { PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), e => EngineError::Other(e.to_string()), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 97ab9a1d..b0bdffbc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -44,6 +44,7 @@ mod patterncmds; pub mod pdfcmds; mod place; pub mod prefscmds; +pub mod print; pub mod rasterfx; mod recolor; mod select; @@ -228,6 +229,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(fileio::eps::specs()); v.extend(package::specs()); v.extend(slices::specs()); + v.extend(print::specs()); v }) } @@ -351,40 +353,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } -/// A 1-based page/artboard range such as `"1-3, 5"` → 0-based indices in the order given (repeats -/// dropped). Each number must lie in `1..=count`; `"3-"` runs to the last one, `"-2"` from the first. -pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result, String> { - let num = |t: &str, open: usize| -> std::result::Result { - let t = t.trim(); - if t.is_empty() { - return Ok(open); - } - t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) - }; - let mut out = vec![]; - for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { - let (a, b) = match part.replace('\u{2013}', "-").split_once('-') { - Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")), - Some((a, b)) => (num(a, 1)?, num(b, count)?), - None => { - let n = num(part, 0)?; - (n, n) - } - }; - if a > b { - return Err(format!("range `{s}`: `{part}` runs backwards")); - } - for i in a - 1..b { - if !out.contains(&i) { - out.push(i); - } - } - } - if out.is_empty() { - return Err(format!("range `{s}` is empty")); - } - Ok(out) -} +pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see /// [`clock_date`]), `Some(None)` for null (no date), else `Some(Some(seconds))`. diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 00000000..c3b3e203 --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -0,0 +1,101 @@ +//! File → Print as a print-ready PDF (`file.print`), what it would print (`print.preview`) and the +//! print settings saved with the document (`print.setup`, [`vectorcraft_doc::Document::print_setup`]). + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_pdf::{PrintOptions, PrintSettings}; + +use super::fileio::pdf::{merge, pdf_error}; +use super::fileio::write_or_return; +use super::*; + +const SETUP: &str = "print.setup"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "print.setup", + "Print Setup", + [], + None, + "{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values in separations)}}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)", + has_doc, + setup + ), + cmd!( + query "print.preview", + "Print Preview", + [], + None, + "{settings?: {…print.setup settings} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %]}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, options not applied yet…), settings} without printing", + has_doc, + preview + ), + cmd!( + "file.print", + "Print", + [], + None, + "{settings?: {…print.setup settings} (over the document's), path?} print the document as a print-ready PDF, one page per sheet of paper (per tile, per ink, per copy) → {pages, path, bytes, warnings}; no path → {dataBase64, bytes, pages, warnings}. Raster effects print at the document's raster effects resolution; halftone screens and flatness are left to the output device", + has_doc, + print + ), + ] +} + +/// The print settings saved with `doc` (defaults if never set up) as JSON. +fn saved(doc: &Document) -> Value { + doc.print_setup.clone().unwrap_or_else(|| serde_json::to_value(PrintSettings::default()).unwrap_or_default()) +} + +/// The settings of `doc` with `p.settings` over them, checked. +fn settings(cmd: &str, doc: &Document, p: &Value) -> Result { + let mut v = saved(doc); + match p.get("settings") { + None | Some(Value::Null) => {} + Some(over @ Value::Object(_)) => merge(&mut v, over), + Some(_) => return Err(bad(cmd, "settings must be an object")), + } + let set = PrintSettings::deserialize(&v).map_err(|e| bad(cmd, format!("print settings: {e}")))?; + set.check().map_err(|e| pdf_error(cmd, e))?; + Ok(set) +} + +fn to_json(set: &PrintSettings) -> Result { + serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string())) +} + +fn setup(s: &mut Session, p: &Value) -> Result { + let doc = &s.doc()?.doc; + let set = to_json(&settings(SETUP, doc, p)?)?; + if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) { + let stored = set.clone(); + s.edit("Print Setup", |d, _| { + d.print_setup = Some(stored); + Ok(()) + })?; + } + Ok(json!({ "settings": set })) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + const C: &str = "print.preview"; + let doc = &s.doc()?.doc; + let set = settings(C, doc, p)?; + let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; + let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?; + v["settings"] = to_json(&set)?; + Ok(v) +} + +fn print(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.print"; + let doc = &s.doc()?.doc; + let set = settings(C, doc, p)?; + // Raster effects print as images at the document's raster effects resolution. + let flat = super::rasterfx::flatten_raster_effects(doc); + let r = + vectorcraft_pdf::print(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(C, e))?; + write_or_return(str_param(p, "path"), &r.bytes, json!({ "pages": r.pages, "warnings": r.warnings })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2bb2d64a..c14cd248 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1168,6 +1168,8 @@ mod tests_prefs; #[cfg(test)] mod tests_previewbounds; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_rastersettings; diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs new file mode 100644 index 00000000..43b52e90 --- /dev/null +++ b/crates/engine/src/tests_print.rs @@ -0,0 +1,113 @@ +//! File → Print: `print.setup` saves the settings with the document (one undo step, kept in the +//! native file), `print.preview` lists what would print, `file.print` writes the PDF. + +use serde_json::{Value, json}; + +use super::*; + +/// A 300 × 200 RGB document with a rectangle; and the rectangle's id. +fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(); + (s, id) +} + +/// `file.print {p}` → (PDF bytes, its answer). +fn print(s: &mut Session, p: Value) -> (Vec, Value) { + let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + (vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(), v) +} + +#[test] +fn setup_is_saved_with_the_document_as_one_undo_step() { + let (mut s, _) = session(); + assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 1, "defaults before any setup"); + assert!(s.doc().unwrap().doc.print_setup.is_none(), "reading the settings changes nothing"); + let v = s.execute("print.setup", &json!({"settings": {"copies": 3, "media": "a4", "marks": {"trim": true}}})).unwrap(); + assert_eq!((v["settings"]["copies"].clone(), v["settings"]["media"].clone()), (json!(3), json!("a4"))); + let undo = s.doc().unwrap().history.undo.len(); + // Further options go over the saved ones; the same settings again are no new step. + let v = s.execute("print.setup", &json!({"settings": {"collate": false}})).unwrap(); + let got = (v["settings"]["copies"].clone(), v["settings"]["collate"].clone(), v["settings"]["marks"]["trim"].clone()); + assert_eq!(got, (json!(3), json!(false), json!(true))); + s.execute("print.setup", &json!({"settings": {"collate": false}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Saved in the native file, and gone again after undoing both steps. + let doc = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(back.print_setup, doc.print_setup); + assert!(back.print_setup.as_ref().is_some_and(|p| p["copies"] == 3)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.print_setup.is_none()); + // Files from before print settings load without them. + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + v.as_object_mut().unwrap().remove("print_setup"); + assert!(vectorcraft_format::load(v.to_string().as_bytes()).unwrap().print_setup.is_none()); +} + +#[test] +fn print_writes_the_saved_setup_with_overrides() { + let (mut s, _) = session(); + s.execute("print.setup", &json!({"settings": {"media": "a4", "scaling": "fit"}})).unwrap(); + let (bytes, v) = print(&mut s, json!({"settings": {"copies": 2}})); + assert_eq!(v["pages"], 2); + let info = vectorcraft_pdf::info(&bytes, None).unwrap(); + assert_eq!(info.pages.len(), 2); + // A4 turned landscape for the wide artboard. + assert_eq!((info.pages[0].width.round(), info.pages[0].height.round()), (842.0, 595.0)); + assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 1, "printing doesn't change the saved setup"); + // To a file. + let path = std::env::temp_dir().join(format!("vc-print-{}.pdf", std::process::id())); + let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(v["pages"], 1); + assert_eq!(std::fs::read(&path).unwrap().len() as u64, v["bytes"].as_u64().unwrap()); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn preview_lists_pages_tiles_inks_and_warnings() { + let (mut s, _) = session(); + let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}, "copies": 2}})).unwrap(); + assert_eq!(v["pages"], 8); + assert_eq!( + v["sheets"].as_array().unwrap().iter().map(|p| p["ink"].as_str().unwrap()).collect::>(), + ["Cyan", "Magenta", "Yellow", "Black"] + ); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("RGB")), "{}", v["warnings"]); + assert_eq!(v["settings"]["copies"], 2); + let v = s.execute("print.preview", &json!({"settings": {"scaling": "tileFull", "scale": {"width": 400, "height": 400}}})).unwrap(); + let tiles = &v["tiles"][0]; + assert_eq!((tiles["columns"].clone(), tiles["rows"].clone()), (json!(2), json!(2)), "{v}"); + assert_eq!(v["pages"], 4); + assert_eq!(v["sheets"][3]["tile"], 4); +} + +#[test] +fn bad_settings_are_bad_params() { + let (mut s, _) = session(); + for p in [ + json!({"settings": "letter"}), + json!({"settings": {"copies": 0}}), + json!({"settings": {"copies": -1}}), + json!({"settings": {"media": "quarto"}}), + json!({"settings": {"artboards": "range", "range": "2"}}), + json!({"settings": {"scaling": "tileFull", "overlap": 500}}), + ] { + for c in ["file.print", "print.preview"] { + assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}"); + } + } + // The setup refuses what it can check without laying the document out. + assert!(matches!(s.execute("print.setup", &json!({"settings": {"copies": 1000}})), Err(EngineError::BadParams { .. }))); + assert!(s.doc().unwrap().doc.print_setup.is_none()); +} + +#[test] +fn raster_effects_print_as_images() { + let (mut s, id) = session(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + let (bytes, _) = print(&mut s, json!({})); + assert!(String::from_utf8_lossy(&bytes).contains("/Image")); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 323cef3a..4bf68cb6 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -49,71 +49,20 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result PdfVersion::Pdf14, - Compatibility::Pdf15 => PdfVersion::Pdf15, - Compatibility::Pdf16 => PdfVersion::Pdf16, - Compatibility::Pdf17 => PdfVersion::Pdf17, - Compatibility::Pdf20 => PdfVersion::Pdf20, - }; - let mut cb = ConfigurationBuilder::new().with_version(version); - // `check` has refused the standards (and standard/version pairs) the writer can't produce. - if set.standard == Standard::PdfA2b { - cb = cb.with_archival_validator(Archival::A2_B); - } - let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; - let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() }; - - let mut pdf = krilla::Document::new_with(settings); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into()); - if !title.is_empty() { - meta = meta.title(title.clone()); - } - // File Info. - let info = &doc.metadata; - let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string); - if let Some(a) = given(&info.author) { - meta = meta.authors(vec![a]); - } - if let Some(d) = given(&info.description) { - meta = meta.description(d); - } - if !info.keywords.is_empty() { - meta = meta.keywords(info.keywords.clone()); - } let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix); - let created = created_at.map(date_time); - if let Some(t) = created { - meta = meta.creation_date(t); - } - pdf.set_metadata(meta); + let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). let mut warnings = set.warnings(); let native = opts.native.as_deref().filter(|_| set.preserve_editing); if let Some(native) = native { - pdf.embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created)); + w.pdf.embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time))); } else if set.preserve_editing { warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); } - let mut ex = Exporter { - doc, - warnings: vec![], - images: HashMap::new(), - brushes: None, - knockout: doc.page_knockout, - lab_spots: vec![], - interpolate: set.standard != Standard::PdfA2b, - compression: &set.compression, - non_printing: set.include_non_printing || set.create_layers, - }; - // CMYK documents blend in CMYK, as on screen: their groups' blending space is rewritten (see - // `cmyk_blending`), and transparency at the top of a page is put in a non-isolated group of - // its own (the PDF writer has no page group attributes). - let cmyk = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; - let page_group = doc.page_isolate || doc.page_knockout; - let cmyk_page_group = cmyk && !page_group && doc.layers.iter().any(|l| l.shows_transparency()); + let mut ex = Exporter::new(doc, set); + ex.non_printing = set.include_non_printing || set.create_layers; // Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the // printer's marks around that. let bleed = set.bleed_of(doc); @@ -121,19 +70,142 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result, + warnings: Vec, +} + +/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), +/// which [`Self::view`] maps onto the page. +pub(crate) struct Sheet<'a> { + /// The page's size in points. + pub size: (f64, f64), + /// The TrimBox and BleedBox, in page space (y down from the top-left corner). + pub trim: Rect, + pub bleed: Rect, + /// Drawing space → page space. + pub view: Affine, + /// The part of the drawing space that shows (a tile): art and marks are clipped to it. + pub window: Option, + /// Document space → drawing space. + pub place: Affine, + /// The art drawn, in document space (the bleed box): what lies outside it is left out. + pub area: Rect, + /// Clip the art to [`Self::area`]. + pub clip: bool, + /// Printer's marks, in drawing space. + pub marks: Option<&'a Node>, + /// Invert the page: paper black, ink clear (a film negative). + pub negative: bool, +} + +impl Writer { + /// A PDF with `set`'s version, standard and compression, and `doc`'s metadata under `title`, + /// created at `created` (Unix seconds). With `cmyk`, transparency blends in CMYK. + pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option, cmyk: bool) -> Result { + let version = match set.compatibility { + Compatibility::Pdf14 => PdfVersion::Pdf14, + Compatibility::Pdf15 => PdfVersion::Pdf15, + Compatibility::Pdf16 => PdfVersion::Pdf16, + Compatibility::Pdf17 => PdfVersion::Pdf17, + Compatibility::Pdf20 => PdfVersion::Pdf20, + }; + let mut cb = ConfigurationBuilder::new().with_version(version); + // `check` has refused the standards (and standard/version pairs) the writer can't produce. + if set.standard == Standard::PdfA2b { + cb = cb.with_archival_validator(Archival::A2_B); + } + let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() }; + + let mut pdf = krilla::Document::new_with(settings); + let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into()); + if !title.is_empty() { + meta = meta.title(title.to_string()); + } + // File Info. + let info = &doc.metadata; + let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string); + if let Some(a) = given(&info.author) { + meta = meta.authors(vec![a]); + } + if let Some(d) = given(&info.description) { + meta = meta.description(d); + } + if !info.keywords.is_empty() { + meta = meta.keywords(info.keywords.clone()); + } + if let Some(t) = created.map(date_time) { + meta = meta.creation_date(t); + } + pdf.set_metadata(meta); + Ok(Self { pdf, cmyk, lab_spots: vec![], warnings: vec![] }) + } + + /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. + pub(crate) fn page(&mut self, ex: &mut Exporter, sheet: &Sheet) -> Result<(), PdfError> { + let (w, h) = sheet.size; + let size = Size::from_wh(w.max(1.0) as f32, h.max(1.0) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; + let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); + let mut page = self.pdf.start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); let mut s = page.surface(); - s.push_transform(&xf(Affine::translate((-m.x0, -m.y0)))); - // Art reaches as far as the bleed: the marks lie outside it. - let clip = (m != r).then(|| to_path(&r.to_path(0.1))).flatten(); + let paper = Rect::new(0.0, 0.0, w, h); + if sheet.negative { + cover(&mut s, paper, 255, 1.0); + } + s.push_transform(&xf(sheet.view)); + let window = sheet.window.and_then(|r| to_path(&r.to_path(0.1))); + if let Some(clip) = &window { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + let placed = sheet.place != Affine::IDENTITY; + if placed { + s.push_transform(&xf(sheet.place)); + } + let r = sheet.area; + let clip = sheet.clip.then(|| to_path(&r.to_path(0.1))).flatten(); if let Some(clip) = &clip { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF - // writer has no page group attributes). + // writer has no page group attributes). In CMYK, transparency at the top of a page is put + // in a non-isolated group of its own, whose blending space `cmyk_blending` rewrites. + let doc = ex.doc; + let page_group = doc.page_isolate || doc.page_knockout; + let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency(); if page_group { s.push_isolated(); } else if cmyk_page_group { @@ -147,25 +219,53 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result<(Vec, Vec), PdfError> { + let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + if self.cmyk { + cmyk_blending(&mut bytes); + } + let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) }; + Ok((bytes, self.warnings)) } - let bytes = if ex.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &ex.lab_spots) }; - let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes }; - warnings.extend(ex.warnings); - warnings.dedup(); - Ok(ExportReport { bytes, warnings }) } /// Make the transparency groups of `pdf` blend in DeviceCMYK. The PDF writer gives every group @@ -240,7 +340,7 @@ fn date_time(t: i64) -> krilla::metadata::DateTime { .utc_offset_minute(0) } -struct Exporter<'a> { +pub(crate) struct Exporter<'a> { doc: &'a Document, warnings: Vec, images: HashMap>, @@ -255,7 +355,37 @@ struct Exporter<'a> { /// How images are resampled and compressed. compression: &'a crate::CompressionSettings, /// Layers whose Print option is off are written too. - non_printing: bool, + pub non_printing: bool, + /// How colours are separated into CMYK (the colour settings' intent unless print sets one). + pub intent: vectorcraft_color::cms::Intent, + /// Whether a layer shows transparency (found out on the first page that asks). + transparent: Option, +} + +impl<'a> Exporter<'a> { + /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are + /// left out. + pub(crate) fn new(doc: &'a Document, set: &'a crate::PdfSettings) -> Self { + Self { + doc, + warnings: vec![], + images: HashMap::new(), + brushes: None, + knockout: doc.page_knockout, + lab_spots: vec![], + interpolate: set.standard != Standard::PdfA2b, + compression: &set.compression, + non_printing: false, + intent: vectorcraft_color::cms::active().settings().intent, + transparent: None, + } + } + + /// Whether a layer shows transparency, which CMYK pages put in a group of its own. + fn shows_transparency(&mut self) -> bool { + let doc = self.doc; + *self.transparent.get_or_insert_with(|| doc.layers.iter().any(|l| l.shows_transparency())) + } } /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). @@ -358,8 +488,7 @@ impl Exporter<'_> { /// through the active colour settings, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) { - let cms = vectorcraft_color::cms::active(); - let [cc, m, y, k] = cms.to_cmyk(c, cms.settings().intent); + let [cc, m, y, k] = vectorcraft_color::cms::active().to_cmyk(c, self.intent); return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into(); } color(c) @@ -377,8 +506,7 @@ impl Exporter<'_> { { self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b))); } - let cms = vectorcraft_color::cms::active(); - let full = cms.to_cmyk(&color, cms.settings().intent); + let full = vectorcraft_color::cms::active().to_cmyk(&color, self.intent); let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index a5fe5843..d13e433d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -26,6 +26,8 @@ //! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes. //! The native document a PDF carries comes back too ([`ImportReport::native`], [`editing()`]). //! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]). +//! - [`print()`] lays a document out on paper as File → Print does ([`PrintSettings`]) and writes +//! the job as a print-ready PDF: composite or one page per ink. #![forbid(unsafe_code)] mod editing; @@ -41,6 +43,7 @@ mod lab_spot; mod marks; mod pages; mod presets; +mod print; mod settings; pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing}; @@ -48,6 +51,7 @@ pub use export::{export, export_with_report}; pub use import::{import, import_with_report}; pub use pages::{PageInfo, PdfInfo, info, is_postscript}; pub use presets::*; +pub use print::*; pub use settings::*; use vectorcraft_doc::Document; @@ -205,6 +209,8 @@ mod tests_marks; #[cfg(test)] mod tests_presets; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs index ef71d44d..dc4575f2 100644 --- a/crates/pdf/src/marks.rs +++ b/crates/pdf/src/marks.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::setup::MAX_BLEED; use vectorcraft_doc::{Document, Node, NodeId}; use vectorcraft_geom::Rect; -use crate::{MarkKind, MarkSettings, PdfSettings}; +use crate::{BleedSettings, MarkKind, MarkSettings, PdfSettings}; impl MarkSettings { /// These settings as the shared mark geometry. @@ -33,7 +33,15 @@ impl PdfSettings { /// Setup; one read from a file is kept to 0–[`MAX_BLEED`]) with Use Document Bleed, else the /// settings' own. pub fn bleed_of(&self, doc: &Document) -> [f64; 4] { - if self.bleed.use_document { doc.setup.bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 }) } else { self.bleed.values() } + self.bleed.of(doc) + } +} + +impl BleedSettings { + /// The bleed of `doc`'s pages, `[top, bottom, left, right]` in points: the document's with + /// [`Self::use_document`] (kept to 0–[`MAX_BLEED`]), else these values. + pub fn of(&self, doc: &Document) -> [f64; 4] { + if self.use_document { doc.setup.bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 }) } else { self.values() } } } @@ -59,9 +67,17 @@ impl PageBoxes { /// The page information printed under the marks: the file's title, the artboard (its name and /// number) and the date and time of the export (UTC). pub(crate) fn page_info(doc: &Document, title: &str, artboard: usize, created: Option) -> String { + page_info_of(doc, title, Some(artboard), created) +} + +/// [`page_info`] of a page that may show no artboard (print with artboards ignored). +pub(crate) fn page_info_of(doc: &Document, title: &str, artboard: Option, created: Option) -> String { let title = if title.trim().is_empty() { "Untitled" } else { title.trim() }; - let name = doc.artboards.get(artboard).map_or("", |a| a.name.as_str()); - let mut info = format!("{title} · {name} ({} of {})", artboard + 1, doc.artboards.len()); + let mut info = title.to_string(); + if let Some(i) = artboard { + let name = doc.artboards.get(i).map_or("", |a| a.name.as_str()); + info.push_str(&format!(" · {name} ({} of {})", i + 1, doc.artboards.len())); + } if let Some(t) = created { let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); info.push_str(&format!(" · {y:04}-{mo:02}-{d:02} {h:02}:{mi:02} UTC")); diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs new file mode 100644 index 00000000..4603e87b --- /dev/null +++ b/crates/pdf/src/print/layout.rs @@ -0,0 +1,277 @@ +//! Where each printed page's art goes: the artboards (or all the art) that print, the paper's +//! orientation, the scale, the placement on the imageable area or the tiles, and the turns and +//! mirroring of the page (flipped orientation, emulsion down, transverse). +//! +//! The art is placed in a drawing space: the paper (y down from its top-left corner), or, when +//! tiling, the whole printed area (the artboard grown by its bleed and marks) from its top-left +//! corner, of which each page shows one tile. + +use kurbo::{Affine, Rect}; +use serde::Serialize; +use vectorcraft_doc::marks::outset; +use vectorcraft_doc::range::parse_range; +use vectorcraft_doc::{Document, Node, NodeKind}; + +use super::{Emulsion, Orientation, PrintArtboards, PrintScaling, PrintSettings}; +use crate::PdfError; + +/// Most tiles of one artboard. +pub const MAX_TILES: usize = 1000; + +/// One page of the job, before copies and inks. +#[derive(Clone, Debug)] +pub(crate) struct Layout { + /// The artboard (`None`: all the art, artboards ignored). + pub artboard: Option, + /// The tile (0-based) of how many. + pub tile: Option<(usize, usize)>, + /// The page size in points (turned when transverse). + pub size: (f64, f64), + /// How the paper is turned. + pub orientation: Orientation, + /// Horizontal and vertical scale (1 = 100%). + pub scale: (f64, f64), + /// Drawing space → page space. + pub view: Affine, + /// The tile, in drawing space. + pub window: Option, + /// Document space → drawing space. + pub place: Affine, + /// The art that prints on this page, in document space (within the bleed box). + pub area: Rect, + /// The artboard in drawing space, and its bleed there (`[top, bottom, left, right]`): where + /// the marks go. + pub trim: Rect, + pub bleed: [f64; 4], + /// The TrimBox and BleedBox in page space. + pub page_trim: Rect, + pub page_bleed: Rect, +} + +/// The tiles of one artboard (`print.preview`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TileGrid { + /// 0-based artboard (`None`: artboards ignored). + pub artboard: Option, + pub columns: usize, + pub rows: usize, + /// The 1-based tiles that print. + pub printed: Vec, + /// Every tile as `[x0, y0, x1, y1]` in document space, across then down. + pub tiles: Vec<[f64; 4]>, +} + +/// Calls `f` with the bounds of each object of `n` that prints: visible, not on a template +/// layer and not a guide (layers are already picked by the Print Layers option). +fn printed(n: &Node, f: &mut impl FnMut(Rect)) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => {} + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children { + printed(c, f); + } + } + _ => { + if let Some(b) = n.visual_bounds() { + f(b); + } + } + } +} + +/// Is there art that prints in `r`? +fn has_art(doc: &Document, r: Rect) -> bool { + let mut any = false; + for l in &doc.layers { + printed(l, &mut |b| any |= b.intersect(r).area() > 0.0 || r.contains_rect(b)); + } + any +} + +/// The bounds of all the art that prints. +fn art_bounds(doc: &Document) -> Option { + let mut all: Option = None; + for l in &doc.layers { + printed(l, &mut |b| all = Some(all.map_or(b, |a| a.union(b)))); + } + all +} + +/// The regions that print: `(artboard, rect)`. +fn regions(doc: &Document, set: &PrintSettings) -> Result, Rect)>, PdfError> { + let count = doc.artboards.len(); + let picked: Vec = match set.artboards { + PrintArtboards::Ignore => { + let r = art_bounds(doc).ok_or_else(|| PdfError::BadSetting("nothing to print: the document has no art that prints".into()))?; + return Ok(vec![(None, r)]); + } + _ if count == 0 => return Err(PdfError::NoArtboards), + PrintArtboards::All => (0..count).collect(), + PrintArtboards::Range => parse_range(&set.range, count).map_err(PdfError::BadSetting)?, + }; + let mut out: Vec<(Option, Rect)> = picked.into_iter().filter_map(|i| Some((Some(i), doc.artboards.get(i)?.rect))).collect(); + if set.skip_blank { + out.retain(|(_, r)| has_art(doc, *r)); + if out.is_empty() { + return Err(PdfError::BadSetting("nothing to print: every artboard picked is blank".into())); + } + } + Ok(out) +} + +/// `[top, bottom, left, right]` sides scaled by `(sx, sy)`. +fn scaled(sides: [f64; 4], (sx, sy): (f64, f64)) -> [f64; 4] { + [sides[0] * sy, sides[1] * sy, sides[2] * sx, sides[3] * sx] +} + +/// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from +/// the paper onto the page, and the page's size. +fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) { + let mut v = Affine::IDENTITY; + if flipped { + v = Affine::new([-1.0, 0.0, 0.0, -1.0, w, h]) * v; + } + if set.output.emulsion == Emulsion::Down { + v = Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]) * v; + } + if set.transverse { + // A quarter turn clockwise: the page is the paper on its side. + return (Affine::new([0.0, 1.0, -1.0, 0.0, h, 0.0]) * v, (h, w)); + } + (v, (w, h)) +} + +/// The pages of `doc` (whose layers are those that print) as `set` lays them out, the tiles of +/// each artboard when tiling, and warnings (art that doesn't fit the imageable area). +pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec) -> Result<(Vec, Vec), PdfError> { + let marks = set.marks.printer_marks(); + let bleed = set.bleed.of(doc); + let (pw, ph) = set.paper(); + let (mut pages, mut grids) = (vec![], vec![]); + for (artboard, r) in regions(doc, set)? { + let what = artboard.map_or_else(|| "The art".to_string(), |i| format!("Artboard {}", i + 1)); + if !(r.width() > 0.0 && r.height() > 0.0) { + warnings.push(format!("{what} has no area and was left out")); + continue; + } + let orientation = match set.auto_rotate { + true if (r.width() > r.height()) != (pw > ph) => Orientation::Landscape, + true => Orientation::Portrait, + false => set.orientation, + }; + let paper = if orientation.landscape() { (ph, pw) } else { (pw, ph) }; + let m = set.margin; + let imageable = Rect::new(m, m, paper.0 - m, paper.1 - m); + if imageable.width() < 1.0 || imageable.height() < 1.0 { + return Err(PdfError::BadSetting(format!("margin: a {m} pt margin leaves no imageable area on the paper"))); + } + // How far the bleed and marks reach around the artboard at scale `s`, `[top, bottom, left, right]`. + let around = |s: (f64, f64)| { + let b = scaled(bleed, s); + let reach = marks.reach(b); + (b, std::array::from_fn::(|i| b[i].max(reach[i]))) + }; + let scale = match set.scaling { + PrintScaling::None => (1.0, 1.0), + PrintScaling::Fit => { + // The largest scale whose artboard with its bleed and marks fits: the marks reach + // further when a larger scale grows the bleed. + let fit = |s: f64| { + let (_, most) = around((s, s)); + ((imageable.width() - most[2] - most[3]) / r.width()).min((imageable.height() - most[0] - most[1]) / r.height()) + }; + let first = fit(1.0); + let s = if first <= 1.0 { first } else { fit(first) }; + if !(s.is_finite() && s > 0.0) { + return Err(PdfError::BadSetting(format!("{what} doesn't fit the paper with its bleed and marks"))); + } + (s, s) + } + _ => (set.scale.width / 100.0, set.scale.height / 100.0), + }; + let (bleed_s, most) = around(scale); + let size = (r.width() * scale.0, r.height() * scale.1); + let extent = (most[2] + size.0 + most[3], most[0] + size.1 + most[1]); + let flipped = !set.auto_rotate && orientation.flipped(); + let (turn, page_size) = turns(set, paper, flipped); + let page = Rect::new(0.0, 0.0, page_size.0, page_size.1); + let bleed_box = outset(r, bleed); + let place_at = |trim: Rect| { + Affine::translate(trim.origin().to_vec2()) * Affine::scale_non_uniform(scale.0, scale.1) * Affine::translate(-r.origin().to_vec2()) + }; + // The page's boxes: the trim box within the bleed box within the page. + let boxes = |view: Affine, trim: Rect| { + let b = view.transform_rect_bbox(outset(trim, bleed_s)).intersect(page); + let t = view.transform_rect_bbox(trim).intersect(b); + if t.area() > 0.0 { (t, b) } else { (page, page) } + }; + let base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout { + artboard, + tile, + size: page_size, + orientation, + scale, + view, + window, + place, + area, + trim, + bleed: bleed_s, + page_trim, + page_bleed, + }; + if !set.scaling.tiles() { + let (fx, fy) = set.placement.origin.factors(); + let x = imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x; + let y = imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y; + let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1); + if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) { + warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print")); + } + pages.push(base(turn, None, place_at(trim), bleed_box, trim, boxes(turn, trim), None)); + continue; + } + // Tiles across then down over the printed area, overlapping by `overlap`. + let tile = if set.scaling == PrintScaling::TileFull { paper } else { (imageable.width(), imageable.height()) }; + let o = set.overlap; + if o * 2.0 >= tile.0.min(tile.1) { + return Err(PdfError::BadSetting(format!("overlap: {o} pt is half a tile or more"))); + } + let step = (tile.0 - o, tile.1 - o); + let (cols, rows) = (((extent.0 - o) / step.0).ceil().max(1.0), ((extent.1 - o) / step.1).ceil().max(1.0)); + if cols * rows > MAX_TILES as f64 { + return Err(PdfError::BadSetting(format!("{what} would print on more than {MAX_TILES} tiles: scale it down"))); + } + let (cols, rows) = (cols as usize, rows as usize); + let count = cols * rows; + let pick = if set.tile_range.trim().is_empty() { + (0..count).collect() + } else { + parse_range(&set.tile_range, count).map_err(|e| PdfError::BadSetting(format!("tileRange of {what}: {e}")))? + }; + let trim = Rect::new(most[2], most[0], most[2] + size.0, most[0] + size.1); + let place = place_at(trim); + let from_doc = place.inverse(); + let at = if set.scaling == PrintScaling::TileFull { (0.0, 0.0) } else { (imageable.x0, imageable.y0) }; + let window = |t: usize| { + let (c, rw) = ((t % cols) as f64, (t / cols) as f64); + Rect::new(c * step.0, rw * step.1, c * step.0 + tile.0, rw * step.1 + tile.1) + }; + for &t in &pick { + let w = window(t); + let view = turn * Affine::translate((at.0 - w.x0, at.1 - w.y0)); + let area = from_doc.transform_rect_bbox(w).intersect(bleed_box); + pages.push(base(view, Some(w), place, area, trim, boxes(view, trim), Some((t, count)))); + } + let tiles = (0..count).map(|t| from_doc.transform_rect_bbox(window(t))).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect(); + grids.push(TileGrid { artboard, columns: cols, rows, printed: pick.iter().map(|t| t + 1).collect(), tiles }); + } + if pages.is_empty() { + return Err(PdfError::BadSetting("nothing to print".into())); + } + Ok((pages, grids)) +} diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs new file mode 100644 index 00000000..6c856c46 --- /dev/null +++ b/crates/pdf/src/print/mod.rs @@ -0,0 +1,287 @@ +//! File → Print as a print-ready PDF: each page is a sheet of the chosen paper with the art laid +//! out on it as the [`PrintSettings`] say. +//! +//! - General: copies (collated or not), reverse order, the artboards (all, a range or ignored: +//! all the art as one page), blank artboards skipped, the paper and its orientation (or turned +//! to each artboard's), transverse, which layers print (template layers never do), the +//! placement on the imageable area, and the scale: none, fit, custom, or tiles of the paper or +//! of its imageable area, overlapping, a range of them. +//! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]), +//! around the artboard on the paper, at the paper's scale. +//! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the +//! grey of its coverage (overprints honoured, spot colours as process if asked), emulsion down +//! (mirrored), negative (inverted). +//! - Colour management: the rendering intent colours are separated with, and whether CMYK +//! colours keep their numbers. +//! +//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`] +//! lists the pages, tiles and inks without writing the file. + +mod layout; +mod plates; +mod settings; + +use std::borrow::Cow; +use std::sync::Arc; + +use serde::Serialize; +use vectorcraft_doc::marks::PrinterMarks; +use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; + +pub use layout::{MAX_TILES, TileGrid}; +pub use plates::print_inks; +pub use settings::*; + +use crate::export::{Exporter, Sheet, Writer}; +use crate::marks::PageBoxes; +use crate::{CompressionSettings, PdfError, PdfSettings}; +use layout::Layout; +use plates::Separator; + +/// Most pages of a job, copies and inks included. +pub const MAX_PAGES: usize = 2000; + +/// A print job: the settings plus what one run decides. +#[derive(Clone, Debug, Default)] +pub struct PrintOptions { + pub settings: PrintSettings, + /// The title in the metadata and the page information; `None` = the document's title. + pub title: Option, + /// When the job ran, Unix seconds (UTC); `None` = now (native) / none (wasm). + pub created: Option, + /// Content streams uncompressed (readable operators, for tests and debugging). + pub uncompressed: bool, +} + +/// A printed job. +#[derive(Clone, Debug)] +pub struct PrintReport { + pub bytes: Vec, + pub pages: usize, + pub warnings: Vec, +} + +/// One page of a job, as [`preview`] lists it (one copy). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageSummary { + /// 0-based artboard (`None`: artboards ignored). + pub artboard: Option, + /// 1-based tile. + #[serde(skip_serializing_if = "Option::is_none")] + pub tile: Option, + /// The ink of a separation. + #[serde(skip_serializing_if = "Option::is_none")] + pub ink: Option, + /// The page size in points. + pub width: f64, + pub height: f64, + pub orientation: Orientation, + /// Horizontal and vertical scale in percent. + pub scale: [f64; 2], +} + +/// What a job prints, without printing it. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintPreview { + /// Pages in all, copies included. + pub pages: usize, + /// One copy's pages, in order. + pub sheets: Vec, + /// The tiles of each artboard, when tiling. + pub tiles: Vec, + /// The inks of a separation (empty for composite output). + pub inks: Vec, + pub warnings: Vec, +} + +/// A laid-out job. +struct Job<'a> { + /// The document as it prints: live effects applied, the layers that print. + doc: Cow<'a, Document>, + layouts: Vec, + tiles: Vec, + /// The inks of a separation (every one, printed or not). + inks: Vec, + /// The inks that print (indices into `inks`): each a plate. + plates: Vec, + /// One copy's pages: (layout, plate). + sheets: Vec<(usize, Option)>, + warnings: Vec, +} + +/// Keep the layers of `nodes` that print as `which` says, shown (hidden ones print with All), +/// down through sublayers. +fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { + nodes.retain_mut(|n| { + let NodeKind::Layer { printable, .. } = n.kind else { return true }; + let keep = match which { + PrintLayers::VisiblePrintable => n.visible && printable, + PrintLayers::Visible => n.visible, + PrintLayers::All => true, + }; + if keep { + let layer = Arc::make_mut(n); + layer.visible = true; + if let Some(children) = layer.children_mut() { + keep_layers(children, which); + } + } + keep + }); +} + +impl<'a> Job<'a> { + fn new(doc: &'a Document, set: &PrintSettings) -> Result { + set.check()?; + // Live effects print as their result (raster effects are rendered by the app first). + let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); + keep_layers(&mut doc.to_mut().layers, set.print_layers); + let mut warnings = vec![]; + let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?; + let separations = set.output.mode == OutputMode::Separations; + let (inks, more) = if separations { print_inks(&doc, set) } else { Default::default() }; + warnings.extend(more); + let plates: Vec = (0..inks.len()).filter(|i| inks[*i].print).collect(); + let printed: Vec> = if separations { (0..plates.len()).map(Some).collect() } else { vec![None] }; + if printed.is_empty() { + return Err(PdfError::BadSetting("output.inks: no ink is set to print".into())); + } + let sheets: Vec<(usize, Option)> = (0..layouts.len()).flat_map(|l| printed.iter().map(move |i| (l, *i))).collect(); + if sheets.len().saturating_mul(set.copies as usize) > MAX_PAGES { + return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); + } + if separations && doc.color_mode == ColorMode::Rgb { + warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into()); + } + if separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { + warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); + } + if !separations && doc.layers.iter().any(|l| l.has_overprint()) { + warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into()); + } + if !set.graphics.auto_flatness { + warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); + } + Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings }) + } + + /// The ink of plate `plate` (`None`: composite). + fn ink(&self, plate: Option) -> Option<&PrintInk> { + self.inks.get(*self.plates.get(plate?)?) + } + + /// Every page, copies included, in print order: (layout, ink). + fn pages(&self, set: &PrintSettings) -> Vec<(usize, Option)> { + let copies = set.copies.max(1) as usize; + let mut v: Vec<_> = if set.collate { + (0..copies).flat_map(|_| self.sheets.iter().copied()).collect() + } else { + self.sheets.iter().flat_map(|s| std::iter::repeat_n(*s, copies)).collect() + }; + if set.reverse { + v.reverse(); + } + v + } +} + +/// What printing `doc` with `set` makes: pages, tiles, inks and warnings. +pub fn preview(doc: &Document, set: &PrintSettings) -> Result { + let job = Job::new(doc, set)?; + let sheets = job + .sheets + .iter() + .filter_map(|(l, plate)| { + let l = job.layouts.get(*l)?; + Some(PageSummary { + artboard: l.artboard, + tile: l.tile.map(|t| t.0 + 1), + ink: job.ink(*plate).map(|i| i.name.clone()), + width: l.size.0, + height: l.size.1, + orientation: l.orientation, + scale: [l.scale.0 * 100.0, l.scale.1 * 100.0], + }) + }) + .collect(); + Ok(PrintPreview { pages: job.sheets.len() * set.copies.max(1) as usize, sheets, tiles: job.tiles, inks: job.inks, warnings: job.warnings }) +} + +/// Print `doc` to a PDF as `opts` say. +pub fn print(doc: &Document, opts: &PrintOptions) -> Result { + let set = &opts.settings; + let job = Job::new(doc, set)?; + let doc = &*job.doc; + let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() }; + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix); + let separations = set.output.mode == OutputMode::Separations; + let mut w = Writer::new(doc, &pdf, &title, created, !separations && doc.color_mode == ColorMode::Cmyk)?; + // One exporter per ink (one for composite), each drawing its own document. + let sep = Separator::new(doc, set); + let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); + let mut exporters: Vec = + if separations { plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] }; + for ex in &mut exporters { + ex.non_printing = true; + ex.intent = set.color.intent; + } + let marks = set.marks.printer_marks(); + let pages = job.pages(set); + for (l, plate) in &pages { + let (Some(l), Some(ex)) = (job.layouts.get(*l), exporters.get_mut(plate.unwrap_or(0))) else { continue }; + let ink = job.ink(*plate); + let art = marks_art(doc, &marks, l, ink, &title, created).map(|mut art| { + if let Some(ink) = ink { + sep.node(&ink.name, &mut art); + } + art + }); + let sheet = Sheet { + size: l.size, + trim: l.page_trim, + bleed: l.page_bleed, + view: l.view, + window: l.window, + place: l.place, + area: l.area, + clip: true, + marks: art.as_ref(), + negative: set.output.image == PrintImage::Negative, + }; + w.page(ex, &sheet)?; + } + for ex in exporters { + w.absorb(ex); + } + let (bytes, more) = w.finish()?; + let mut warnings = job.warnings; + for m in more { + if !warnings.contains(&m) { + warnings.push(m); + } + } + Ok(PrintReport { bytes, pages: pages.len(), warnings }) +} + +/// The printer's marks of page `l` (`None` without marks), with its page information: the +/// title, artboard and date, the tile, and the ink with its screen. +fn marks_art(doc: &Document, marks: &PrinterMarks, l: &Layout, ink: Option<&PrintInk>, title: &str, created: Option) -> Option { + if !marks.any() { + return None; + } + let mut info = String::new(); + if marks.page_info { + info = crate::marks::page_info_of(doc, title, l.artboard, created); + if let Some((t, n)) = l.tile { + info.push_str(&format!(" · tile {} of {n}", t + 1)); + } + if let Some(ink) = ink { + info.push_str(&format!(" · {} {} lpi {}°", ink.name, ink.frequency, ink.angle)); + } + } + let boxes = PageBoxes::new(l.trim, l.bleed, marks); + crate::marks::art(doc, marks, &boxes, l.bleed, &info) +} diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs new file mode 100644 index 00000000..aa13934b --- /dev/null +++ b/crates/pdf/src/print/plates.rs @@ -0,0 +1,107 @@ +//! Separations: the inks of a job ([`print_inks`]) and the document as one ink's plate, every +//! colour (and image pixel) replaced by the grey of that ink's coverage. + +use std::sync::Arc; + +use vectorcraft_color::Color; +use vectorcraft_color::cms::{Cms, Model}; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors}; +use vectorcraft_doc::overprint::multiply_overprints; +use vectorcraft_doc::{ColorMode, Document, Node}; + +use super::{DEFAULT_FREQUENCY, PrintInk, PrintSettings}; + +/// The inks of a separation of `doc`: the process inks, then one per spot swatch (none when +/// spots print as process), with the options of [`super::PrintOutput::inks`]; and a warning for +/// each option naming no ink. +pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec, Vec) { + let out = &set.output; + let list: Vec = plates(doc) + .into_iter() + .filter(|p| !(p.spot && out.spots_to_process)) + .map(|p| { + let o = out.inks.iter().find(|i| i.name == p.name); + PrintInk { + print: o.is_none_or(|o| o.print), + frequency: o.and_then(|o| o.frequency).unwrap_or(DEFAULT_FREQUENCY), + angle: o.and_then(|o| o.angle).unwrap_or_else(|| PrintInk::default_angle(&p.name)), + spot: p.spot, + name: p.name, + } + }) + .collect(); + let warnings = out + .inks + .iter() + .filter(|i| !list.iter().any(|p| p.name == i.name)) + .map(|i| { + format!("there is no ink named `{}` to print (inks: {})", i.name, list.iter().map(|p| p.name.as_str()).collect::>().join(", ")) + }) + .collect(); + (list, warnings) +} + +/// Separates colours into inks as the job's colour options say. +pub(crate) struct Separator<'a> { + pub doc: &'a Document, + cms: Arc, + set: &'a PrintSettings, +} + +impl<'a> Separator<'a> { + pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self { + Self { doc, cms: vectorcraft_color::cms::active(), set } + } + + /// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1. + fn ink(&self, plate: &str, c: &Color, link: Link) -> f32 { + let color = &self.set.color; + // Spot colours as process: only Registration keeps its link. + let link = link.filter(|(name, _)| !self.set.output.spots_to_process || *name == REGISTRATION); + // Without Preserve Numbers, CMYK colours are separated again from their appearance. + let lab; + let c = if !color.preserve_numbers && matches!(c, Color::Cmyk { .. }) { + lab = self.cms.convert(c, Model::Lab, color.intent); + &lab + } else { + c + }; + inks(self.doc, &self.cms, c, link, color.intent).on_plate(plate) + } + + /// Recolour `n`'s subtree as plate `plate`: the grey of each colour's coverage, unlinked. + pub fn node(&self, plate: &str, n: &mut Node) { + visit_node_colors(n, &mut |c, swatch, tint| { + *c = Color::Gray { k: self.ink(plate, c, swatch.as_deref().map(|s| (s, *tint))) }; + *swatch = None; + *tint = 1.0; + }); + } + + /// The document as plate `plate`: art, symbols, pattern tiles and images in the grey of the + /// ink's coverage. Overprinting fills and strokes multiply, so where they have none of this + /// ink the plate below shows through instead of being knocked out. + pub fn plate(&self, plate: &str) -> Document { + let mut d = self.doc.clone(); + let discard_white = d.setup.discard_white_overprint; + let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); + for n in nodes { + multiply_overprints(n, discard_white); + self.node(plate, Arc::make_mut(n)); + } + for blob in d.images.values_mut() { + let gray = blob.map_rgb(|[r, g, b]| { + let rgb = |v: u8| v as f32 / 255.0; + let v = (255.0 * (1.0 - self.ink(plate, &Color::rgb(rgb(r), rgb(g), rgb(b)), None))).round() as u8; + [v; 3] + }); + if let Some(gray) = gray { + *blob = gray; + } + } + // Every colour is a grey now: nothing to separate on the way out. + d.color_mode = ColorMode::Rgb; + d + } +} diff --git a/crates/pdf/src/print/settings.rs b/crates/pdf/src/print/settings.rs new file mode 100644 index 00000000..97b6bb56 --- /dev/null +++ b/crates/pdf/src/print/settings.rs @@ -0,0 +1,406 @@ +//! The Print dialog's model ([`PrintSettings`]): camelCase JSON whose every field is optional and +//! falls back to its default, the checks that refuse values out of range, and the inks of a +//! separation ([`PrintInk`]). + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::cms::Intent; + +use crate::settings::{choice, within}; +use crate::{BleedSettings, MarkSettings, PdfError}; + +choice! { + /// Which artboards print. + PrintArtboards { + All = "all", "All"; + /// The 1-based artboards [`PrintSettings::range`] names. + Range = "range", "Range"; + /// All the art as one page, whatever the artboards. + Ignore = "ignore", "Ignore Artboards"; + } default All +} + +choice! { + /// The paper (portrait sizes; [`Media::Custom`] is [`PrintSettings::width`] × + /// [`PrintSettings::height`]). + Media { + Letter = "letter", "Letter"; + Legal = "legal", "Legal"; + Tabloid = "tabloid", "Tabloid"; + A3 = "a3", "A3"; + A4 = "a4", "A4"; + A5 = "a5", "A5"; + B4 = "b4", "B4"; + B5 = "b5", "B5"; + Custom = "custom", "Custom"; + } default Letter +} + +impl Media { + /// The paper size in points, portrait (`None` for a custom size). + pub fn size(self) -> Option<(f64, f64)> { + let mm = |w: f64, h: f64| Some((w * 72.0 / 25.4, h * 72.0 / 25.4)); + match self { + Self::Letter => Some((612.0, 792.0)), + Self::Legal => Some((612.0, 1008.0)), + Self::Tabloid => Some((792.0, 1224.0)), + Self::A3 => mm(297.0, 420.0), + Self::A4 => mm(210.0, 297.0), + Self::A5 => mm(148.0, 210.0), + Self::B4 => mm(250.0, 353.0), + Self::B5 => mm(176.0, 250.0), + Self::Custom => None, + } + } +} + +choice! { + /// How the paper is turned. + Orientation { + Portrait = "portrait", "Portrait"; + Landscape = "landscape", "Landscape"; + /// Portrait, the art upside down. + PortraitFlipped = "portraitFlipped", "Portrait (flipped)"; + /// Landscape, the art upside down. + LandscapeFlipped = "landscapeFlipped", "Landscape (flipped)"; + } default Portrait +} + +impl Orientation { + pub fn landscape(self) -> bool { + matches!(self, Self::Landscape | Self::LandscapeFlipped) + } + + pub fn flipped(self) -> bool { + matches!(self, Self::PortraitFlipped | Self::LandscapeFlipped) + } +} + +choice! { + /// Which layers print (template layers never do). + PrintLayers { + VisiblePrintable = "visiblePrintable", "Visible & Printable Layers"; + Visible = "visible", "Visible Layers"; + /// Hidden layers and layers whose Print option is off too. + All = "all", "All Layers"; + } default VisiblePrintable +} + +choice! { + /// The point of the printed area that sits on the same point of the imageable area. + Origin { + TopLeft = "topLeft", "Top Left"; + Top = "top", "Top"; + TopRight = "topRight", "Top Right"; + Left = "left", "Left"; + Center = "center", "Center"; + Right = "right", "Right"; + BottomLeft = "bottomLeft", "Bottom Left"; + Bottom = "bottom", "Bottom"; + BottomRight = "bottomRight", "Bottom Right"; + } default Center +} + +impl Origin { + /// Where the point lies across and down a box (0, ½ or 1). + pub fn factors(self) -> (f64, f64) { + match self { + Self::TopLeft => (0.0, 0.0), + Self::Top => (0.5, 0.0), + Self::TopRight => (1.0, 0.0), + Self::Left => (0.0, 0.5), + Self::Center => (0.5, 0.5), + Self::Right => (1.0, 0.5), + Self::BottomLeft => (0.0, 1.0), + Self::Bottom => (0.5, 1.0), + Self::BottomRight => (1.0, 1.0), + } + } +} + +choice! { + /// How the art is sized on the paper. + PrintScaling { + None = "none", "Do Not Scale"; + /// As large as fits the imageable area with its marks, keeping its proportions. + Fit = "fit", "Fit to Page"; + /// [`PrintSettings::scale`]. + Custom = "custom", "Custom Scale"; + /// Pages the size of the paper, overlapping by [`PrintSettings::overlap`]. + TileFull = "tileFull", "Tile Full Pages"; + /// Pages the size of the imageable area. + TileImageable = "tileImageable", "Tile Imageable Areas"; + } default None +} + +impl PrintScaling { + pub fn tiles(self) -> bool { + matches!(self, Self::TileFull | Self::TileImageable) + } +} + +choice! { + /// Composite colour, or one page per ink. + OutputMode { + Composite = "composite", "Composite"; + Separations = "separations", "Separations"; + } default Composite +} + +choice! { + /// Which side of the film the image is read from: Down mirrors the page. + Emulsion { + Up = "up", "Up (right reading)"; + Down = "down", "Down (right reading)"; + } default Up +} + +choice! { + /// Positive, or a film negative (the page inverted). + PrintImage { + Positive = "positive", "Positive"; + Negative = "negative", "Negative"; + } default Positive +} + +choice! { + /// Which fonts are sent to the printer. + FontDownload { + None = "none", "None"; + Subset = "subset", "Subset"; + Complete = "complete", "Complete"; + } default Subset +} + +/// File → Print settings (the dialog's sections; what [`vectorcraft_doc::Document::print_setup`] +/// stores). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintSettings { + // General. + /// Copies of the whole job (1–999). + pub copies: u32, + /// Copies as whole sets (1 2 3, 1 2 3), not page by page (1 1, 2 2, 3 3). + pub collate: bool, + /// Last page first. + pub reverse: bool, + pub artboards: PrintArtboards, + /// 1-based artboards with [`PrintArtboards::Range`] (`"1-3, 5"`). + pub range: String, + /// Leave out artboards with no art that prints. + pub skip_blank: bool, + pub media: Media, + /// Custom paper size (pt). + pub width: f64, + pub height: f64, + pub orientation: Orientation, + /// Turn the paper to the art's orientation, artboard by artboard (the orientation is ignored). + pub auto_rotate: bool, + /// Turn the page a quarter turn on the paper (roll-fed devices). + pub transverse: bool, + pub print_layers: PrintLayers, + pub placement: Placement, + pub scaling: PrintScaling, + /// Width and height in percent, with custom scale and tiling. + pub scale: PrintScale, + /// How much tiles overlap (pt). + pub overlap: f64, + /// 1-based tiles to print (`"1-3, 5"`, numbered across then down); empty: every tile. + pub tile_range: String, + /// The device's unprintable margin around the paper (pt): the imageable area is inside it. + pub margin: f64, + // Marks and Bleed. + pub marks: MarkSettings, + pub bleed: BleedSettings, + pub output: PrintOutput, + pub graphics: PrintGraphics, + pub color: PrintColor, +} + +impl Default for PrintSettings { + fn default() -> Self { + Self { + copies: 1, + collate: true, + reverse: false, + artboards: PrintArtboards::All, + range: String::new(), + skip_blank: false, + media: Media::Letter, + width: 612.0, + height: 792.0, + orientation: Orientation::Portrait, + auto_rotate: true, + transverse: false, + print_layers: PrintLayers::VisiblePrintable, + placement: Placement::default(), + scaling: PrintScaling::None, + scale: PrintScale::default(), + overlap: 0.0, + tile_range: String::new(), + margin: 0.0, + marks: MarkSettings::default(), + // The document's bleed (Document Setup) unless other values are given. + bleed: BleedSettings { use_document: true, ..Default::default() }, + output: PrintOutput::default(), + graphics: PrintGraphics::default(), + color: PrintColor::default(), + } + } +} + +/// Where the printed area (the artboard with its bleed and marks) sits on the paper. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placement { + pub origin: Origin, + /// Moves it right and down from there (pt). + pub x: f64, + pub y: f64, +} + +/// Custom scale, in percent. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintScale { + pub width: f64, + pub height: f64, +} + +impl Default for PrintScale { + fn default() -> Self { + Self { width: 100.0, height: 100.0 } + } +} + +/// The Output section. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintOutput { + pub mode: OutputMode, + pub emulsion: Emulsion, + pub image: PrintImage, + /// Separate spot colours into process inks: no spot plates. + pub spots_to_process: bool, + /// Per-ink options, by ink name; inks not listed print with their defaults. + pub inks: Vec, +} + +/// One ink's options in the ink list. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct InkSettings { + /// "Cyan", "Magenta", "Yellow", "Black" or a spot swatch's name. + pub name: String, + /// Print this ink's plate. + pub print: bool, + /// Halftone screen ruling (lpi); `None`: the ink's default. + #[serde(skip_serializing_if = "Option::is_none")] + pub frequency: Option, + /// Halftone screen angle (degrees); `None`: the ink's default. + #[serde(skip_serializing_if = "Option::is_none")] + pub angle: Option, +} + +impl Default for InkSettings { + fn default() -> Self { + Self { name: String::new(), print: true, frequency: None, angle: None } + } +} + +/// An ink of a separation, with its options resolved ([`super::inks`]). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintInk { + pub name: String, + pub spot: bool, + pub print: bool, + pub frequency: f64, + pub angle: f64, +} + +/// The default halftone screen ruling (lpi). +pub const DEFAULT_FREQUENCY: f64 = 60.0; + +impl PrintInk { + /// The default screen angle of ink `name`: the classic process angles (Cyan 15°, Magenta 75°, + /// Yellow 0°, Black 45°), 45° for spot inks. + pub fn default_angle(name: &str) -> f64 { + match name { + "Cyan" => 15.0, + "Magenta" => 75.0, + "Yellow" => 0.0, + _ => 45.0, + } + } +} + +/// The Graphics section. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintGraphics { + /// Let the device pick the flatness curves are drawn with. + pub auto_flatness: bool, + /// The flatness without it (device pixels, 0.2–100: higher is faster and coarser). + pub flatness: f64, + pub fonts: FontDownload, +} + +impl Default for PrintGraphics { + fn default() -> Self { + Self { auto_flatness: true, flatness: 1.0, fonts: FontDownload::Subset } + } +} + +/// The Color Management section. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintColor { + /// How colours outside the press gamut are separated. + pub intent: Intent, + /// CMYK colours print with their own values; off, they go through the colour settings too. + pub preserve_numbers: bool, +} + +impl Default for PrintColor { + fn default() -> Self { + Self { intent: Intent::RelativeColorimetric, preserve_numbers: true } + } +} + +/// Most copies of a job. +pub const MAX_COPIES: u32 = 999; + +impl PrintSettings { + /// Refuse values out of range (ranges and tile overlap are checked against the document when + /// it prints). + pub fn check(&self) -> Result<(), PdfError> { + within("copies", self.copies as f64, 1.0, MAX_COPIES as f64, "")?; + if self.media == Media::Custom { + within("width", self.width, 72.0, 14_400.0, " pt")?; + within("height", self.height, 72.0, 14_400.0, " pt")?; + } + within("placement.x", self.placement.x, -14_400.0, 14_400.0, " pt")?; + within("placement.y", self.placement.y, -14_400.0, 14_400.0, " pt")?; + within("scale.width", self.scale.width, 1.0, 1000.0, "%")?; + within("scale.height", self.scale.height, 1.0, 1000.0, "%")?; + within("overlap", self.overlap, 0.0, 720.0, " pt")?; + within("margin", self.margin, 0.0, 144.0, " pt")?; + self.marks.check()?; + self.bleed.check()?; + within("graphics.flatness", self.graphics.flatness, 0.2, 100.0, "")?; + for ink in &self.output.inks { + if let Some(f) = ink.frequency { + within(&format!("output.inks[{}].frequency", ink.name), f, 1.0, 1000.0, " lpi")?; + } + if let Some(a) = ink.angle { + within(&format!("output.inks[{}].angle", ink.name), a, -360.0, 360.0, "°")?; + } + } + Ok(()) + } + + /// The paper size in points, portrait as chosen (before orientation). + pub fn paper(&self) -> (f64, f64) { + self.media.size().unwrap_or((self.width, self.height)) + } +} diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 7d64079c..d1fe5eff 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -386,7 +386,7 @@ impl Default for SecuritySettings { } /// `v` lies in `lo..=hi` (and is a number). -fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfError> { +pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfError> { if (lo..=hi).contains(&v) { Ok(()) } else { Err(PdfError::BadSetting(format!("{name} must be {lo}–{hi}{unit} (got {v})"))) } } @@ -420,11 +420,8 @@ impl PdfSettings { within(&format!("compression.{name}.ppi"), img.0, 9.0, 2400.0, " ppi")?; within(&format!("compression.{name}.abovePpi"), img.1, 9.0, 2400.0, " ppi")?; } - within("marks.weight", self.marks.weight, 0.05, 2.0, " pt")?; - within("marks.offset", self.marks.offset, 0.0, 72.0, " pt")?; - for (side, v) in ["top", "bottom", "left", "right"].iter().zip(self.bleed.values()) { - within(&format!("bleed.{side}"), v, 0.0, 72.0, " pt")?; - } + self.marks.check()?; + self.bleed.check()?; within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") } @@ -469,3 +466,21 @@ impl PdfSettings { .collect() } } + +impl MarkSettings { + /// Refuse a weight or offset out of range (PDF export and print). + pub(crate) fn check(&self) -> Result<(), PdfError> { + within("marks.weight", self.weight, 0.05, 2.0, " pt")?; + within("marks.offset", self.offset, 0.0, 72.0, " pt") + } +} + +impl BleedSettings { + /// Refuse a side out of range (PDF export and print). + pub(crate) fn check(&self) -> Result<(), PdfError> { + for (side, v) in ["top", "bottom", "left", "right"].iter().zip(self.values()) { + within(&format!("bleed.{side}"), v, 0.0, 72.0, " pt")?; + } + Ok(()) + } +} diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests_print.rs new file mode 100644 index 00000000..5fd74960 --- /dev/null +++ b/crates/pdf/src/tests_print.rs @@ -0,0 +1,353 @@ +//! File → Print as a PDF: paper, scale, placement, tiles, artboard ranges, copies, layers, +//! separations, marks and the page turns. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::{Appearance, Artboard, Document, LayerColor, Node, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_testkit::raster::render_region; + +use crate::*; + +/// A `w` × `h` document with filled rectangles `(rect, paint)` on its one layer. +fn doc(w: f64, h: f64, shapes_: &[(Rect, Paint)]) -> Document { + let mut d = Document::new(w, h); + let layer = d.default_layer().unwrap(); + for (r, p) in shapes_ { + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(*r), Appearance::basic(p.clone(), Paint::None, 0.0))).unwrap(); + } + d +} + +fn artboard(id: u32, x: f64) -> Artboard { + Artboard { id, name: format!("Artboard {id}"), rect: Rect::new(x, 0.0, x + 100.0, 100.0), show_center_mark: false, show_cross_hairs: false } +} + +fn rgb(r: f32, g: f32, b: f32) -> Paint { + Paint::solid(Color::rgb(r, g, b)) +} + +fn settings(v: serde_json::Value) -> PrintSettings { + serde_json::from_value(v).unwrap() +} + +fn job(d: &Document, v: serde_json::Value) -> PrintReport { + let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() }; + print(d, &opts).unwrap() +} + +/// A printed page read back: its size and every filled path on it (bounds in page space, y +/// down; paint), left to right. +type Page = ((f64, f64), Vec<(Rect, Paint)>); + +fn pages(bytes: &[u8]) -> Vec { + let back = import(bytes).unwrap(); + back.artboards + .iter() + .map(|ab| { + let r = ab.rect; + let mut paths = vec![]; + back.walk(|n| { + if let (Some(b), Some(f)) = (n.path_data().and_then(|p| p.bounds()), n.appearance.fill()) + && f.paint.color().is_some() + && b.intersect(r).area() > 0.0 + { + paths.push((b - r.origin().to_vec2(), f.paint.clone())); + } + }); + paths.sort_by(|a, b| a.0.x0.total_cmp(&b.0.x0)); + ((r.width(), r.height()), paths) + }) + .collect() +} + +fn near(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05) +} + +fn rgb8(p: &Paint) -> [u8; 3] { + let c = p.color().unwrap().to_rgba8(1.0); + [c[0], c[1], c[2]] +} + +#[test] +fn defaults_and_json() { + let s = PrintSettings::default(); + assert_eq!( + (s.copies, s.collate, s.media, s.scaling, s.print_layers), + (1, true, Media::Letter, PrintScaling::None, PrintLayers::VisiblePrintable) + ); + assert!(s.auto_rotate && s.bleed.use_document && s.check().is_ok()); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!(v["printLayers"], "visiblePrintable"); + assert_eq!(v["output"]["mode"], "composite"); + assert_eq!(serde_json::from_value::(v).unwrap(), s); + // Every field is optional. + let s = settings(json!({"scaling": "tileFull", "placement": {"origin": "topLeft"}, "color": {"intent": "perceptual"}})); + assert_eq!((s.scaling, s.placement.origin, s.copies), (PrintScaling::TileFull, Origin::TopLeft, 1)); + for bad in [json!({"copies": 0}), json!({"scale": {"width": 0}}), json!({"media": "custom", "width": 10}), json!({"marks": {"weight": 9}})] { + assert!(matches!(settings(bad.clone()).check(), Err(PdfError::BadSetting(_))), "{bad}"); + } + assert_eq!(Media::A4.size().map(|(w, h)| (w.round(), h.round())), Some((595.0, 842.0))); +} + +#[test] +fn fit_to_letter_scales_the_artboard_into_the_paper() { + // Twice Letter, landscape, a red square in its top-left quarter. + let d = doc(1584.0, 1224.0, &[(Rect::new(0.0, 0.0, 792.0, 612.0), rgb(1.0, 0.0, 0.0))]); + let r = job(&d, json!({"scaling": "fit"})); + assert_eq!(r.pages, 1); + let p = pages(&r.bytes); + assert_eq!(p[0].0, (792.0, 612.0), "auto-rotate turns Letter landscape"); + assert!(near(p[0].1[0].0, Rect::new(0.0, 0.0, 396.0, 306.0)), "half size from the corner: {:?}", p[0].1); + // Without auto-rotate a portrait Letter fits it smaller, centred. + let r = job(&d, json!({"scaling": "fit", "autoRotate": false})); + let p = pages(&r.bytes); + assert_eq!(p[0].0, (612.0, 792.0)); + let s = 612.0 / 1584.0; + let top = (792.0 - 1224.0 * s) / 2.0; + assert!(near(p[0].1[0].0, Rect::new(0.0, top, 792.0 * s, top + 612.0 * s)), "{:?}", p[0].1); + let pv = preview(&d, &settings(json!({"scaling": "fit", "autoRotate": false}))).unwrap(); + assert!((pv.sheets[0].scale[0] - s * 100.0).abs() < 1e-9 && pv.sheets[0].orientation == Orientation::Portrait); + // With marks the artboard shrinks to leave them room on the paper. + let pv = preview(&d, &settings(json!({"scaling": "fit", "marks": {"trim": true}}))).unwrap(); + assert!(pv.sheets[0].scale[0] < 50.0 && pv.warnings.is_empty(), "{pv:?}"); +} + +#[test] +fn placement_and_custom_scale() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(0.0, 0.0, 1.0))]); + let at = |v| pages(&job(&d, v).bytes)[0].1[0].0; + assert!(near(at(json!({})), Rect::new(256.0, 346.0, 356.0, 446.0)), "centred"); + assert!(near(at(json!({"placement": {"origin": "topLeft", "x": 10, "y": 20}})), Rect::new(10.0, 20.0, 110.0, 120.0))); + assert!(near(at(json!({"placement": {"origin": "bottomRight"}, "margin": 18})), Rect::new(494.0, 674.0, 594.0, 774.0)), "inside the margin"); + assert!(near( + at(json!({"placement": {"origin": "topLeft"}, "scaling": "custom", "scale": {"width": 200, "height": 50}})), + Rect::new(0.0, 0.0, 200.0, 50.0) + )); + // Art larger than the paper is cut off, with a warning. + let big = doc(1000.0, 100.0, &[]); + assert!(job(&big, json!({"autoRotate": false})).warnings.iter().any(|w| w.contains("larger than the imageable area"))); +} + +#[test] +fn tiles_cover_the_artboard_with_overlap() { + // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. + let d = doc(1000.0, 700.0, &[(Rect::new(560.0, 100.0, 600.0, 140.0), rgb(0.0, 0.6, 0.0))]); + let set = json!({"scaling": "tileFull", "overlap": 36, "autoRotate": false}); + let pv = preview(&d, &settings(set.clone())).unwrap(); + assert_eq!((pv.pages, pv.tiles[0].columns, pv.tiles[0].rows), (2, 2, 1)); + assert_eq!(pv.tiles[0].tiles[1][0], 576.0, "the second tile starts a tile less the overlap along"); + let p = pages(&job(&d, set).bytes); + assert_eq!(p.len(), 2); + // The square lies in the overlap: on both pages, where each tile shows it. + assert!(near(p[0].1[0].0, Rect::new(560.0, 100.0, 600.0, 140.0)), "{:?}", p[0].1); + assert!(near(p[1].1[0].0, Rect::new(-16.0, 100.0, 24.0, 140.0)), "{:?}", p[1].1); + // 1100 × 1400: 2 × 2, and a tile range. + let d = doc(1100.0, 1400.0, &[]); + let pv = preview(&d, &settings(json!({"scaling": "tileFull", "overlap": 36, "autoRotate": false}))).unwrap(); + assert_eq!((pv.pages, pv.tiles[0].columns, pv.tiles[0].rows), (4, 2, 2)); + let pv = preview(&d, &settings(json!({"scaling": "tileFull", "tileRange": "2-3", "autoRotate": false}))).unwrap(); + assert_eq!((pv.pages, pv.tiles[0].printed.clone()), (2, vec![2, 3])); + assert_eq!(pv.sheets.iter().map(|s| s.tile).collect::>(), [Some(2), Some(3)]); + assert!(preview(&d, &settings(json!({"scaling": "tileFull", "tileRange": "9"}))).is_err()); + // Imageable-area tiles are smaller (inside the margin). + let pv = preview(&d, &settings(json!({"scaling": "tileImageable", "margin": 72, "autoRotate": false}))).unwrap(); + assert_eq!((pv.tiles[0].columns, pv.tiles[0].rows), (3, 3)); + assert!(preview(&d, &settings(json!({"scaling": "tileFull", "overlap": 400}))).is_err(), "overlap of half a tile"); +} + +#[test] +fn artboard_range_skip_blank_and_ignore() { + let mut d = + doc(100.0, 100.0, &[(Rect::new(10.0, 10.0, 20.0, 20.0), rgb(1.0, 0.0, 0.0)), (Rect::new(410.0, 10.0, 420.0, 20.0), rgb(0.0, 0.0, 1.0))]); + d.artboards.extend([artboard(2, 200.0), artboard(3, 400.0)]); + assert_eq!(job(&d, json!({})).pages, 3); + let p = pages(&job(&d, json!({"artboards": "range", "range": "3, 1"})).bytes); + assert_eq!(p.len(), 2); + assert_eq!(rgb8(&p[0].1[0].1), [0, 0, 255], "in the range's order"); + assert_eq!(rgb8(&p[1].1[0].1), [255, 0, 0]); + assert!(preview(&d, &settings(json!({"artboards": "range", "range": "4"}))).is_err()); + let pv = preview(&d, &settings(json!({"skipBlank": true}))).unwrap(); + assert_eq!(pv.sheets.iter().map(|s| s.artboard).collect::>(), [Some(0), Some(2)], "the middle artboard is blank"); + // Ignoring artboards: all the art on one page. + let pv = preview(&d, &settings(json!({"artboards": "ignore"}))).unwrap(); + assert_eq!((pv.pages, pv.sheets[0].artboard), (1, None)); + assert!(preview(&doc(100.0, 100.0, &[]), &settings(json!({"artboards": "ignore"}))).is_err(), "no art"); +} + +#[test] +fn copies_collate_and_reverse() { + let mut d = + doc(100.0, 100.0, &[(Rect::new(10.0, 10.0, 20.0, 20.0), rgb(1.0, 0.0, 0.0)), (Rect::new(210.0, 10.0, 220.0, 20.0), rgb(0.0, 0.0, 1.0))]); + d.artboards.push(artboard(2, 200.0)); + let order = |v| pages(&job(&d, v).bytes).iter().map(|p| if rgb8(&p.1[0].1) == [255, 0, 0] { 'a' } else { 'b' }).collect::(); + assert_eq!(order(json!({"copies": 2})), "abab"); + assert_eq!(order(json!({"copies": 2, "collate": false})), "aabb"); + assert_eq!(order(json!({"copies": 2, "reverse": true})), "baba"); + assert!(preview(&d, &settings(json!({"copies": 999}))).is_ok_and(|p| p.pages == 1998)); + assert!(preview(&d, &settings(json!({"copies": 999, "output": {"mode": "separations"}}))).is_err(), "too many pages"); +} + +#[test] +fn print_layers_and_template_layers() { + let mut d = doc(200.0, 200.0, &[(Rect::new(0.0, 0.0, 10.0, 10.0), rgb(1.0, 0.0, 0.0))]); + // A hidden layer, a non-printing one and a template, each with a blue square. + for (i, name) in ["Hidden", "Notes", "Template"].into_iter().enumerate() { + let mut layer = Node::layer(d.alloc_id(), name, LayerColor::Preset(1)); + let x = 20.0 + 20.0 * i as f64; + let sq = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(rgb(0.0, 0.0, 1.0), Paint::None, 0.0)); + if let NodeKind::Layer { children, printable, template, .. } = &mut layer.kind { + *printable = name != "Notes"; + *template = name == "Template"; + children.push(Arc::new(sq)); + } + layer.visible = name != "Hidden"; + d.layers.push(Arc::new(layer)); + } + let blues = |v| pages(&job(&d, v).bytes)[0].1.iter().filter(|(_, p)| rgb8(p) == [0, 0, 255]).count(); + assert_eq!(blues(json!({})), 0, "visible & printable: the hidden and non-printing layers are left out"); + assert_eq!(blues(json!({"printLayers": "visible"})), 1, "the non-printing layer"); + assert_eq!(blues(json!({"printLayers": "all"})), 2, "the hidden layer too, never the template"); +} + +#[test] +fn cmyk_and_a_spot_separate_into_five_plates() { + let mut d = doc(200.0, 100.0, &[]); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + let layer = d.default_layer().unwrap(); + let cyan = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)); + let gold = Paint::Solid { color: Color::cmyk(0.0, 0.1, 0.4, 0.05), swatch: Some("Gold".into()), tint: 0.5 }; + for (r, p) in [(Rect::new(0.0, 0.0, 50.0, 50.0), cyan), (Rect::new(100.0, 0.0, 150.0, 50.0), gold)] { + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id, shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0))).unwrap(); + } + let set = json!({"output": {"mode": "separations"}, "marks": {"trim": true}}); + let pv = preview(&d, &settings(set.clone())).unwrap(); + assert_eq!(pv.inks.iter().map(|i| i.name.as_str()).collect::>(), ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + assert_eq!(pv.sheets.iter().filter_map(|s| s.ink.clone()).collect::>(), ["Cyan", "Magenta", "Yellow", "Black", "Gold"]); + assert_eq!((pv.inks[0].angle, pv.inks[3].angle, pv.inks[0].frequency), (15.0, 45.0, DEFAULT_FREQUENCY)); + let r = job(&d, set); + assert_eq!(r.pages, 5); + assert!(!String::from_utf8_lossy(&r.bytes).contains("/Separation"), "plates are grey"); + // The ink of the cyan and the gold square on a plate, in percent (0: none, a knockout). + let inks = |page: &Page| -> Vec { + page.1 + .iter() + .filter(|(b, _)| (b.width() - 50.0).abs() < 1.0 && (b.height() - 50.0).abs() < 1.0) + .map(|(_, p)| match p.color() { + Some(Color::Gray { k }) => (k * 100.0).round(), + c => panic!("{c:?}"), + }) + .collect() + }; + let p = pages(&r.bytes); + assert_eq!(inks(&p[0]), [100.0, 0.0], "cyan plate"); + assert_eq!(inks(&p[1]), [0.0, 0.0], "magenta plate: both knock out"); + assert_eq!(inks(&p[4]), [0.0, 50.0], "the spot plate at the tint"); + // Trim marks print on every plate, in full ink. + let back = import(&r.bytes).unwrap(); + let mut strokes = 0; + back.walk(|n| strokes += usize::from(n.appearance.stroke().is_some_and(|s| matches!(s.paint.color(), Some(Color::Gray { k }) if k > 0.99)))); + assert_eq!(strokes, 8 * 5, "8 trim marks on each plate"); + // Spots as process: four plates, the gold in process inks; an ink can be left out. + let pv = preview(&d, &settings(json!({"output": {"mode": "separations", "spotsToProcess": true, "inks": [{"name": "Black", "print": false}]}}))) + .unwrap(); + assert_eq!(pv.pages, 3); + let p = pages(&job(&d, json!({"output": {"mode": "separations", "spotsToProcess": true}})).bytes); + assert_eq!(p.len(), 4); + assert_eq!(inks(&p[2]), [0.0, 40.0], "the gold's yellow"); +} + +#[test] +fn trim_marks_surround_the_artboard_on_the_paper() { + let d = doc(200.0, 100.0, &[(Rect::new(0.0, 0.0, 200.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let r = job(&d, json!({"marks": {"trim": true}, "autoRotate": false})); + let pdf = info(&r.bytes, None).unwrap(); + let trim = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Trim).unwrap().1; + assert_eq!((trim.width().round(), trim.height().round()), (200.0, 100.0), "the TrimBox is the artboard on the paper"); + let back = import(&r.bytes).unwrap(); + let ab = Rect::new(206.0, 346.0, 406.0, 446.0) + back.artboards[0].rect.origin().to_vec2(); + let mut marks = vec![]; + back.walk(|n| { + if n.appearance.stroke().is_some_and(|s| s.paint.is_registration()) + && let Some(b) = n.path_data().and_then(|p| p.bounds()) + { + marks.push(b); + } + }); + assert_eq!(marks.len(), 8); + assert!(marks.iter().all(|m| m.intersect(ab.inflate(-0.5, -0.5)).area() <= 0.0 && ab.inflate(40.0, 40.0).contains_rect(*m)), "{marks:?}"); + // The document's bleed by default: the BleedBox is the artboard grown by it. + let mut bled = d.clone(); + bled.setup.bleed = [9.0; 4]; + let pdf = info(&job(&bled, json!({"autoRotate": false})).bytes, None).unwrap(); + let bleed = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Bleed).unwrap().1; + assert_eq!((bleed.width().round(), bleed.height().round()), (218.0, 118.0)); + let pdf = info(&job(&bled, json!({"autoRotate": false, "bleed": {"useDocument": false}})).bytes, None).unwrap(); + let bleed = pdf.pages[0].boxes.iter().find(|(c, _)| *c == CropTo::Bleed).unwrap().1; + assert_eq!((bleed.width().round(), bleed.height().round()), (200.0, 100.0)); +} + +#[test] +fn turns_mirror_and_negative() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let first = |v| pages(&job(&d, v).bytes).remove(0); + let with = |k: &str, v| { + let mut s = json!({"placement": {"origin": "topLeft"}, "autoRotate": false}); + s[k] = v; + s + }; + assert!(near(first(with("output", json!({"emulsion": "down"}))).1[0].0, Rect::new(562.0, 0.0, 612.0, 100.0)), "mirrored"); + assert!(near(first(with("orientation", json!("portraitFlipped"))).1[0].0, Rect::new(562.0, 692.0, 612.0, 792.0)), "upside down"); + assert_eq!(first(with("orientation", json!("landscape"))).0, (792.0, 612.0)); + let p = first(with("transverse", json!(true))); + assert_eq!(p.0, (792.0, 612.0), "a quarter turn"); + assert!(near(p.1[0].0, Rect::new(692.0, 0.0, 792.0, 50.0)), "{:?}", p.1); + // A negative: black paper, the red square cyan. + let back = import(&job(&d, with("output", json!({"image": "negative"}))).bytes).unwrap(); + let img = render_region(&back, back.artboards[0].rect, 1.0); + assert_eq!(img.over_white(300, 300), [0, 0, 0]); + let c = img.over_white(25, 50); + assert!(c[0] < 10 && c[1] > 245 && c[2] > 245, "{c:?}"); +} + +#[test] +fn golden_page_one() { + // 300 × 200: red left half, blue right half, a black frame; on Letter, turned landscape, + // centred at 100%, with trim marks and registration targets. + let mut d = + doc(300.0, 200.0, &[(Rect::new(0.0, 0.0, 150.0, 200.0), rgb(1.0, 0.0, 0.0)), (Rect::new(150.0, 0.0, 300.0, 200.0), rgb(0.0, 0.0, 1.0))]); + let layer = d.default_layer().unwrap(); + let id = d.alloc_id(); + d.insert( + Some(layer), + 2, + Node::path(id, shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)), Appearance::basic(Paint::None, rgb(0.0, 0.0, 0.0), 4.0)), + ) + .unwrap(); + let r = job(&d, json!({"marks": {"trim": true, "registration": true, "weight": 2}})); + let back = import(&r.bytes).unwrap(); + let page = back.artboards[0].rect; + assert_eq!((page.width(), page.height()), (792.0, 612.0)); + let img = render_region(&back, page, 1.0); + let (x, y) = (246, 206); + for (px, py, want) in [ + (x + 75, y + 100, [255, 0, 0]), + (x + 225, y + 100, [0, 0, 255]), + (x + 10, y + 100, [0, 0, 0]), + (x + 150, y + 10, [0, 0, 0]), + (40, 40, [255, 255, 255]), + (x - 3, y + 100, [255, 255, 255]), + // A trim mark along the top edge, left of the corner past the 6 pt offset. + (x - 12, y, [0, 0, 0]), + ] { + let c = img.over_white(px, py); + assert!(c.iter().zip(want).all(|(a, b)| a.abs_diff(b) < 40), "({px}, {py}): {c:?} vs {want:?}"); + } +} diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 01921895..9a274ecf 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -18,191 +18,26 @@ //! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`]. use std::borrow::Cow; -use std::sync::{Arc, RwLock}; +use std::sync::RwLock; +use vectorcraft_color::Color; use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; -use vectorcraft_color::swatch::REGISTRATION; -use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_doc::Document; +pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; +use vectorcraft_doc::overprint::multiply_overprints; use crate::RenderOptions; -/// Ink coverage of one paint colour. -#[derive(Clone, Debug, PartialEq)] -pub struct Inks { - /// Process inks C, M, Y, K (0..1). - pub cmyk: [f32; 4], - /// Spot ink (swatch name, tint 0..1). - pub spot: Option<(String, f32)>, - /// Registration: the tint (every process ink's) prints on every spot plate too. - pub registration: bool, -} - -impl Inks { - /// The tint this colour prints on spot plate `name`. - pub fn spot_tint(&self, name: &str) -> f32 { - match &self.spot { - _ if self.registration => self.cmyk[0], - Some((n, t)) if n == name => *t, - _ => 0.0, - } - } -} - -/// A printing plate. -#[derive(Clone, Debug, PartialEq)] -pub struct Plate { - pub name: String, - pub spot: bool, - /// Display colour of the ink (for the panel swatch). - pub rgb: [f32; 3], -} - -/// The plates of `doc`: the four process plates plus one per spot swatch. -pub fn plates(doc: &Document) -> Vec { - let c = cms::active(); - let mut v: Vec = PROCESS_PLATES - .iter() - .enumerate() - .map(|(i, n)| { - let mut ink = [0.0; 4]; - ink[i] = 1.0; - Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) } - }) - .collect(); - for sw in doc.swatches_iter() { - if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint) - && !v.iter().any(|p| p.name == sw.name) - { - v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(&doc.linked_color(*color, true)) }); - } - } - v -} - -/// The ink colour of spot swatch `name` as it shows ([`Document::linked_color`]). -fn spot_color(doc: &Document, name: &str) -> Option { - let s = doc.swatch(name).filter(|s| s.spot)?; - s.paint.color().map(|c| doc.linked_color(c, true)) -} - -/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. -pub type Link<'a> = Option<(&'a str, f32)>; - -/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at -/// its tint; one linked to the Registration swatch prints its tint on every plate. -pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { - if let Some((REGISTRATION, tint)) = link { - return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; - } - if let Some((name, tint)) = link - && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) - { - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; - } - Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } -} - -fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Link) -> Color) { - match p { - Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))), - Paint::Gradient(g) => { - for s in &mut g.gradient.stops { - s.color = f(&s.color, s.swatch.as_deref().map(|n| (n, s.tint))); - } - } - _ => {} - } -} - -/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points) given its swatch link and tint, keeping the links. -pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(l) => map_paint(&mut l.paint, f), - AppearanceItem::Stroke(l) => map_paint(&mut l.paint, f), - } - } - match &mut n.kind { - NodeKind::Text(t) => { - for r in &mut t.runs { - map_paint(&mut r.style.fill, f); - map_paint(&mut r.style.stroke, f); - } - } - NodeKind::Mesh(m) => { - for p in &mut m.points { - p.color = f(&p.color, None); - } - } - _ => {} - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - map_node_colors(Arc::make_mut(c), f); - } - } -} - -/// Apply `f` to every colour in the document's art and symbol definitions. -pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) { - for l in &mut doc.layers { - map_node_colors(Arc::make_mut(l), f); - } - for s in &mut doc.symbols { - map_node_colors(Arc::make_mut(&mut s.art), f); - } -} - /// Whether overprinting shows: Overprint Preview, or Separations Preview (which implies it). pub(crate) fn overprints(opts: &RenderOptions) -> bool { opts.overprint_preview || opts.proof.as_ref().is_some_and(|p| p.separations.is_some()) } -/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend -/// mode of their own keep it), copying only the nodes on the way to them. With `discard_white` -/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead. -fn multiply_overprints(a: &mut Arc, discard_white: bool) { - if !a.has_overprint() { - return; - } - let n = Arc::make_mut(a); - let multiplies = - |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, - AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, - _ => {} - } - } - for c in n.children_mut().into_iter().flatten() { - multiply_overprints(c, discard_white); - } -} - -/// A solid paint of paper white (no ink in any colour model). -fn is_white(p: &Paint) -> bool { - let Paint::Solid { color, .. } = p else { return false }; - match *color { - Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0, - Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0, - Color::Gray { k } => k <= 0.0, - Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0, - } -} - fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color { let ink = inks(doc, c, color, link, proof.intent); let rgb = if visible.len() == 1 { - let name = &visible[0]; - let v = match PROCESS_PLATES.iter().position(|p| p == name) { - Some(i) => ink.cmyk[i], - None => ink.spot_tint(name), - }; - [1.0 - v; 3] + [1.0 - ink.on_plate(&visible[0]); 3] } else { let mut cmyk = ink.cmyk; for (i, p) in PROCESS_PLATES.iter().enumerate() { diff --git a/docs/mcp.md b/docs/mcp.md index d5d406af..a033aa4d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1228,3 +1228,38 @@ Screens doesn't write EPS. {"name":"run_command","arguments":{"command":"document.exportEps","params":{"path":"/tmp/logo.eps","level":2,"previewFormat":"tiffBw","flattenerPreset":"high"}}} {"name":"export","arguments":{"path":"/tmp/art.eps","options":{"useArtboards":true,"cmykPostScript":false}}} ``` + +## Print + +File → Print writes the job as a print-ready PDF (there is no printer driver): each page is a sheet of the chosen +paper. `file.print {settings?, path?}` → `{pages, path | dataBase64, bytes, warnings}`; `print.preview {settings?}` +answers what it would print without writing it: `{pages (copies included), sheets: [{artboard, tile?, ink?, width, +height, orientation, scale}] (one copy, in order), tiles: [{artboard, columns, rows, printed, tiles}] (document +space), inks: [{name, spot, print, frequency, angle}], warnings, settings}`. `print.setup {settings?}` saves the +settings with the document (one undo step, kept in the native file as `print_setup`); both other commands start from +the saved settings, with `settings` over them (`null` keeps a value). `list_commands` with filter `print.setup` +documents every field: + +- General: `copies` (1–999), `collate`, `reverse`; `artboards: all|range|ignore` with `range: "1-3, 5"` (ignore: all + the art as one page), `skipBlank`; `media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom` (`width`, `height` in pt), + `orientation`, `autoRotate` (on by default: the paper turns to each artboard), `transverse`; `printLayers: + visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area + (artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull| + tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down). +- `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`), + drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen. +- `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name, + print, frequency, angle}]}`: separations print one page per ink in the grey of its coverage (images too; + overprinting fills and strokes don't knock out), Registration on every plate; emulsion down mirrors the page and a + negative inverts it. +- `graphics: {autoFlatness, flatness, fonts}` and `color: {intent, preserveNumbers}` (the intent colours separate + with; without preserveNumbers CMYK colours are separated again). + +Halftone screens and a fixed flatness are not written to the PDF (the output device's apply): the warnings say so, +as they do for art larger than the imageable area and for overprints in composite output. + +```json +{"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}} +{"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"output":{"mode":"separations"}}}}} +{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}} +```