Merge pull request #105 from storytold/pr/m4-print-engine

M4.75: print engine
This commit is contained in:
BFlat 2026-10-05 15:30:39 +07:00 committed by GitHub
commit 2bab433c92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2186 additions and 299 deletions

157
crates/doc/src/inks.rs Normal file
View File

@ -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<Plate> {
let c = vectorcraft_color::cms::active();
let mut v: Vec<Plate> = 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<Color> {
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<String>, &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);
}
}

View File

@ -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<serde_json::Value>,
}
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))));

View File

@ -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<Node>, 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<String> {

37
crates/doc/src/range.rs Normal file
View File

@ -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<Vec<usize>, String> {
let num = |t: &str, open: usize| -> Result<usize, String> {
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)
}

View File

@ -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<Value> {
pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
let out = match path {
Some(path) => {
write_file(path, bytes)?;

View File

@ -109,7 +109,7 @@ pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfS
/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the
/// preset's value), anything else replaces.
fn merge(base: &mut Value, over: &Value) {
pub(crate) fn merge(base: &mut Value, over: &Value) {
match (base, over) {
(Value::Object(b), Value::Object(o)) => {
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()),

View File

@ -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<Vec<usize>, String> {
let num = |t: &str, open: usize| -> std::result::Result<usize, String> {
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))`.

View File

@ -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<CommandSpec> {
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<PrintSettings> {
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<Value> {
serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))
}
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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 }))
}

View File

@ -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;

View File

@ -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<u8>, 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::<Vec<_>>(),
["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"));
}

View File

@ -49,71 +49,20 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
return Err(PdfError::BadArtboard(*bad));
}
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 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<ExportRep
for i in indices {
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed, &marks);
let (m, r) = (boxes.media, boxes.bleed);
let size = Size::from_wh(m.width().max(1.0) as f32, m.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?;
// The boxes in page space (y-down from the media box's top-left corner, like ours).
let at = |b: Rect| krilla::geom::Rect::from_ltrb((b.x0 - m.x0) as f32, (b.y0 - m.y0) as f32, (b.x1 - m.x0) as f32, (b.y1 - m.y0) as f32);
let mut page = pdf.start_page_with(PageSettings::new(size).with_trim_box(at(boxes.trim)).with_bleed_box(at(boxes.bleed)));
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
// The page is the media box, y down from its top-left corner (like ours).
let view = Affine::translate((-m.x0, -m.y0));
let sheet = Sheet {
size: (m.width(), m.height()),
trim: view.transform_rect_bbox(boxes.trim),
bleed: view.transform_rect_bbox(r),
view,
window: None,
place: Affine::IDENTITY,
area: r,
// Art reaches as far as the bleed: the marks lie outside it.
clip: m != r,
marks: art.as_ref(),
negative: false,
};
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
}
w.absorb(ex);
let (bytes, more) = w.finish()?;
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
warnings.extend(more);
warnings.dedup();
Ok(ExportReport { bytes, warnings })
}
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
pub(crate) struct Writer {
pub pdf: krilla::Document,
/// Groups blend in CMYK (a CMYK document's composite, see `cmyk_blending`).
cmyk: bool,
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
warnings: Vec<String>,
}
/// 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<Rect>,
/// 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<i64>, cmyk: bool) -> Result<Self, PdfError> {
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<ExportRep
if clip.is_some() {
s.pop();
}
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
if let Some(art) = crate::marks::art(doc, &marks, &boxes, bleed, &info) {
if placed {
s.pop();
}
if let Some(art) = sheet.marks {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let knockout = std::mem::replace(&mut ex.knockout, false);
ex.node(&mut s, &art, m, true);
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
ex.knockout = knockout;
}
if window.is_some() {
s.pop();
}
s.pop();
if sheet.negative {
s.push_blend_mode(krilla::blend::BlendMode::Difference);
cover(&mut s, paper, 255, 1.0);
s.pop();
}
s.finish();
page.finish();
Ok(())
}
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
if cmyk {
cmyk_blending(&mut bytes);
/// Take an exporter's warnings and Lab spot colours once its pages are drawn.
pub(crate) fn absorb(&mut self, ex: Exporter) {
for w in ex.warnings {
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
for spot in ex.lab_spots {
if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) {
self.lab_spots.push(spot);
}
}
}
/// The finished file and the warnings of its drawing.
pub(crate) fn finish(self) -> Result<(Vec<u8>, Vec<String>), 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<String>,
images: HashMap<String, Option<Image>>,
@ -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<bool>,
}
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))
}

View File

@ -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;

View File

@ -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<i64>) -> 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<usize>, created: Option<i64>) -> 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"));

View File

@ -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<usize>,
/// 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<Rect>,
/// 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<usize>,
pub columns: usize,
pub rows: usize,
/// The 1-based tiles that print.
pub printed: Vec<usize>,
/// 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<Rect> {
let mut all: Option<Rect> = 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<Vec<(Option<usize>, Rect)>, PdfError> {
let count = doc.artboards.len();
let picked: Vec<usize> = 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<usize>, 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<String>) -> Result<(Vec<Layout>, Vec<TileGrid>), 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::<f64, 4, _>(|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))
}

287
crates/pdf/src/print/mod.rs Normal file
View File

@ -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<String>,
/// When the job ran, Unix seconds (UTC); `None` = now (native) / none (wasm).
pub created: Option<i64>,
/// Content streams uncompressed (readable operators, for tests and debugging).
pub uncompressed: bool,
}
/// A printed job.
#[derive(Clone, Debug)]
pub struct PrintReport {
pub bytes: Vec<u8>,
pub pages: usize,
pub warnings: Vec<String>,
}
/// 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<usize>,
/// 1-based tile.
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<usize>,
/// The ink of a separation.
#[serde(skip_serializing_if = "Option::is_none")]
pub ink: Option<String>,
/// 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<PageSummary>,
/// The tiles of each artboard, when tiling.
pub tiles: Vec<TileGrid>,
/// The inks of a separation (empty for composite output).
pub inks: Vec<PrintInk>,
pub warnings: Vec<String>,
}
/// 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<Layout>,
tiles: Vec<TileGrid>,
/// The inks of a separation (every one, printed or not).
inks: Vec<PrintInk>,
/// The inks that print (indices into `inks`): each a plate.
plates: Vec<usize>,
/// One copy's pages: (layout, plate).
sheets: Vec<(usize, Option<usize>)>,
warnings: Vec<String>,
}
/// 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<Arc<Node>>, 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<Self, PdfError> {
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<usize> = (0..inks.len()).filter(|i| inks[*i].print).collect();
let printed: Vec<Option<usize>> = 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<usize>)> = (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<usize>) -> 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<usize>)> {
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<PrintPreview, PdfError> {
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<PrintReport, PdfError> {
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<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let mut exporters: Vec<Exporter> =
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<i64>) -> Option<Node> {
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)
}

View File

@ -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<PrintInk>, Vec<String>) {
let out = &set.output;
let list: Vec<PrintInk> = 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::<Vec<_>>().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<Cms>,
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
}
}

View File

@ -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<InkSettings>,
}
/// 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<f64>,
/// Halftone screen angle (degrees); `None`: the ink's default.
#[serde(skip_serializing_if = "Option::is_none")]
pub angle: Option<f64>,
}
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))
}
}

View File

@ -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(())
}
}

View File

@ -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<Page> {
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::<PrintSettings>(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::<Vec<_>>(), [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::<Vec<_>>(), [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::<String>();
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::<Vec<_>>(), ["Cyan", "Magenta", "Yellow", "Black", "Gold"]);
assert_eq!(pv.sheets.iter().filter_map(|s| s.ink.clone()).collect::<Vec<_>>(), ["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<f32> {
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:?}");
}
}

View File

@ -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<Plate> {
let c = cms::active();
let mut v: Vec<Plate> = 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<Color> {
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<Node>, 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() {

View File

@ -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}}}}
```