M4.75: File → Print writes the job as a print-ready PDF: paper, scale, tiles, marks and bleed, separations
file.print {settings?, path?} lays the document out on paper and writes one PDF page per sheet:
copies (collated, reversed), artboards (all, a range, ignored), blank artboards skipped, paper
size and orientation (or turned per artboard), transverse, Print Layers (template layers never
print), placement on the imageable area, no scale / fit / custom / tiles of the paper or of the
imageable area with overlap and a tile range; printer's marks and bleed as PDF export draws
them; composite or separations (one grey plate per ink that prints, overprints honoured, spots
as process), emulsion down, negative; rendering intent and preserve numbers. print.preview lists
the pages, tiles, inks and warnings; print.setup saves the settings with the document
(Document.print_setup, one undo step).
PDF export and print share one page writer; the ink separation helpers move from the renderer to
vectorcraft_doc::inks and the range parser to vectorcraft_doc::range so print can use them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d87b71ed4
commit
b379f02527
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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))`.
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
35
docs/mcp.md
35
docs/mcp.md
|
|
@ -1228,3 +1228,38 @@ Screens doesn't write EPS.
|
|||
{"name":"run_command","arguments":{"command":"document.exportEps","params":{"path":"/tmp/logo.eps","level":2,"previewFormat":"tiffBw","flattenerPreset":"high"}}}
|
||||
{"name":"export","arguments":{"path":"/tmp/art.eps","options":{"useArtboards":true,"cmykPostScript":false}}}
|
||||
```
|
||||
|
||||
## Print
|
||||
|
||||
File → Print writes the job as a print-ready PDF (there is no printer driver): each page is a sheet of the chosen
|
||||
paper. `file.print {settings?, path?}` → `{pages, path | dataBase64, bytes, warnings}`; `print.preview {settings?}`
|
||||
answers what it would print without writing it: `{pages (copies included), sheets: [{artboard, tile?, ink?, width,
|
||||
height, orientation, scale}] (one copy, in order), tiles: [{artboard, columns, rows, printed, tiles}] (document
|
||||
space), inks: [{name, spot, print, frequency, angle}], warnings, settings}`. `print.setup {settings?}` saves the
|
||||
settings with the document (one undo step, kept in the native file as `print_setup`); both other commands start from
|
||||
the saved settings, with `settings` over them (`null` keeps a value). `list_commands` with filter `print.setup`
|
||||
documents every field:
|
||||
|
||||
- General: `copies` (1–999), `collate`, `reverse`; `artboards: all|range|ignore` with `range: "1-3, 5"` (ignore: all
|
||||
the art as one page), `skipBlank`; `media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom` (`width`, `height` in pt),
|
||||
`orientation`, `autoRotate` (on by default: the paper turns to each artboard), `transverse`; `printLayers:
|
||||
visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area
|
||||
(artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull|
|
||||
tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down).
|
||||
- `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`),
|
||||
drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen.
|
||||
- `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name,
|
||||
print, frequency, angle}]}`: separations print one page per ink in the grey of its coverage (images too;
|
||||
overprinting fills and strokes don't knock out), Registration on every plate; emulsion down mirrors the page and a
|
||||
negative inverts it.
|
||||
- `graphics: {autoFlatness, flatness, fonts}` and `color: {intent, preserveNumbers}` (the intent colours separate
|
||||
with; without preserveNumbers CMYK colours are separated again).
|
||||
|
||||
Halftone screens and a fixed flatness are not written to the PDF (the output device's apply): the warnings say so,
|
||||
as they do for art larger than the imageable area and for overprints in composite output.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}}
|
||||
{"name":"run_command","arguments":{"command":"print.preview","params":{"settings":{"output":{"mode":"separations"}}}}}
|
||||
{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue