diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index f8d84007..be585f6a 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -126,7 +126,7 @@ struct Params { } /// A string or number param as text (`2018` and `"2018"` alike). -fn text(v: &Value) -> String { +pub(crate) fn text(v: &Value) -> String { match v { Value::String(s) => s.clone(), other => other.to_string(), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a732a71c..cac1500e 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -152,10 +152,7 @@ fn parse(p: &Value) -> Result { let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?; let mut flatten = flattener(&q, &[])?; flatten.preserve_overprints &= overprint == Overprint::Preserve; - let level = q.level.as_ref().map(|v| match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }); + let level = q.level.as_ref().map(super::dxf::text); let eps = EpsOptions { level: choice("level", level, Level::from_id, d.level, "2 or 3")?, preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 3fd4ba06..dbebd0eb 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,13 +1,16 @@ -//! 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`]). +//! File → Print as a print-ready PDF or a PostScript file (`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_eps::{Level, PrintJob, PrintPage}; use vectorcraft_pdf::{PrintOptions, PrintSettings}; use super::fileio::pdf::{merge, pdf_error}; -use super::fileio::write_or_return; +use super::fileio::{extension, write_or_return}; +use super::flatten::{FlattenOptions, flatten_document}; use super::*; const SETUP: &str = "print.setup"; @@ -37,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{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", + "{settings?: {…print.setup settings} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -93,13 +96,76 @@ fn preview(s: &mut Session, p: &Value) -> Result { Ok(v) } +const PRINT: &str = "file.print"; + +/// Whether `file.print` writes PostScript: `format`, else a `.ps` path. +fn to_postscript(p: &Value) -> Result { + match str_param(p, "format") { + None => Ok(str_param(p, "path").is_some_and(|path| extension(path) == "ps")), + Some("postscript") => Ok(true), + Some("pdf") => Ok(false), + Some(f) => Err(bad(PRINT, format!("format `{f}`: pdf or postscript"))), + } +} + fn print(s: &mut Session, p: &Value) -> Result { - const C: &str = "file.print"; + let postscript = to_postscript(p)?; + let flattener = if postscript { + let preset = str_param(p, "flattenerPreset").unwrap_or("medium"); + Some( + FlattenOptions::find_preset(preset, &s.prefs.flattener_presets) + .ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?, + ) + } else { + None + }; let doc = &s.doc()?.doc; - let set = settings(C, doc, p)?; + let set = settings(PRINT, 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 })) + let doc = flat.as_ref().unwrap_or(doc); + let path = str_param(p, "path"); + let Some(flattener) = flattener else { + let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?; + return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); + }; + let level = match p.get("level").filter(|v| !v.is_null()) { + Some(v) => { + let t = super::fileio::dxf::text(v); + Level::from_id(&t).ok_or_else(|| bad(PRINT, format!("level `{t}`: 2 or 3")))? + } + None => Level::default(), + }; + // PostScript has no transparency. + let flat = flatten_document(doc, &flattener)?; + let mut warnings = vec![]; + if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) { + warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); + } + let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); + let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) + .map_err(|e| pdf_error(PRINT, e))?; + let pages: Vec = plan + .sheets + .iter() + .map(|sh| PrintPage { + doc: sh.plate.and_then(|i| plan.plates.get(i)).unwrap_or(&plan.doc), + size: sh.size, + view: sh.view, + window: sh.window, + place: sh.place, + area: sh.area, + marks: sh.marks.as_ref(), + negative: sh.negative, + ink: sh.ink.as_ref().map(|i| (i.name.as_str(), i.frequency, i.angle)), + }) + .collect(); + let job = PrintJob { level, title: plan.title.clone(), created: plan.created, flatness }; + let out = vectorcraft_eps::print(&pages, &plan.order, &job).map_err(|e| bad(PRINT, e))?; + for w in plan.warnings.iter().chain(&out.warnings) { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8eaebb10..54f1592a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1219,6 +1219,8 @@ mod tests_printpresets; #[cfg(test)] mod tests_printpreview; #[cfg(test)] +mod tests_printps; +#[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_rastersettings; diff --git a/crates/engine/src/tests_printps.rs b/crates/engine/src/tests_printps.rs new file mode 100644 index 00000000..4eeebc7b --- /dev/null +++ b/crates/engine/src/tests_printps.rs @@ -0,0 +1,96 @@ +//! File → Print to a PostScript file: `file.print {format: "postscript"}` writes the job the PDF +//! print lays out as DSC-conforming PostScript. + +use serde_json::{Value, json}; + +use super::*; + +/// A document of `artboards` 300 × 200 artboards with a rectangle on the first. +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s +} + +/// `file.print {format: "postscript", …p}` → (the PostScript, the answer). +fn print_ps(s: &mut Session, mut p: Value) -> (String, Value) { + p["format"] = json!("postscript"); + let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), v) +} + +/// The values of DSC comment `key` (`%%Key:`), in order. +fn dsc(ps: &str, key: &str) -> Vec { + let prefix = format!("%%{key}:"); + ps.lines().filter_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()).collect() +} + +#[test] +fn pages_equals_the_page_count() { + let mut s = session(3); + let (ps, v) = print_ps(&mut s, json!({})); + assert_eq!((v["pages"].clone(), v["format"].clone()), (json!(3), json!("postscript"))); + assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n")); + assert_eq!(dsc(&ps, "Pages"), ["3"]); + assert_eq!(dsc(&ps, "Page").len(), 3); + assert_eq!(dsc(&ps, "LanguageLevel"), ["3"]); + // Copies, a range of artboards, and the level. + let (ps, v) = print_ps(&mut s, json!({"level": 2, "settings": {"copies": 2, "artboards": "range", "range": "1-2"}})); + assert_eq!((v["pages"].clone(), dsc(&ps, "Pages")), (json!(4), vec!["4".to_string()])); + assert_eq!(dsc(&ps, "Page").len(), 4); + assert_eq!(dsc(&ps, "LanguageLevel"), ["2"]); + // Letter paper, turned to the wide artboards. + assert_eq!(dsc(&ps, "PageBoundingBox")[0], "0 0 792 612"); +} + +#[test] +fn marks_and_separations_are_written() { + let mut s = session(1); + let (ps, _) = print_ps(&mut s, json!({"settings": {"marks": {"trim": true, "registration": true}}})); + // Marks print in Registration: on every ink. + assert!(ps.contains("/All /DeviceCMYK"), "{ps}"); + let (none, _) = print_ps(&mut s, json!({})); + assert!(!none.contains("/All /DeviceCMYK")); + // Separations: a page per ink with its screen. + let (ps, v) = + print_ps(&mut s, json!({"settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "frequency": 120, "angle": 30}]}}})); + assert_eq!(v["pages"], 4); + assert_eq!(dsc(&ps, "PlateColor"), ["Cyan", "Magenta", "Yellow", "Black"]); + assert!(ps.contains("120 30 {dup mul exch dup mul add 1 exch sub} setscreen")); + // PostScript writes the screens, so no warning says they are left to the device. + assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("screens")), "{v}"); + // Negatives and a fixed flatness. + let (ps, _) = print_ps(&mut s, json!({"settings": {"output": {"image": "negative"}, "graphics": {"autoFlatness": false, "flatness": 4}}})); + assert!(ps.contains("{1 exch sub} settransfer") && ps.contains("4 setflat")); +} + +#[test] +fn a_ps_path_prints_postscript_and_pdf_stays_the_default() { + let mut s = session(1); + let v = s.execute("file.print", &json!({})).unwrap(); + assert_eq!(v["format"], "pdf"); + let path = std::env::temp_dir().join(format!("vc-printps-{}.ps", std::process::id())); + let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(v["format"], "postscript"); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0")); + let _ = std::fs::remove_file(&path); + // The file reads back: the rectangle on the first page. + let back = vectorcraft_eps::import(&bytes).unwrap(); + assert!(!back.document.layers[0].children().unwrap().is_empty()); + for bad in [json!({"format": "ps2"}), json!({"format": "postscript", "level": 1}), json!({"format": "postscript", "flattenerPreset": "nope"})] { + assert!(matches!(s.execute("file.print", &bad), Err(EngineError::BadParams { .. })), "{bad}"); + } +} + +#[test] +fn transparency_is_flattened_for_postscript() { + let mut s = session(1); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let (ps, v) = print_ps(&mut s, json!({})); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened")), "{v}"); + assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("opaque: EPS")), "{v}"); + assert_eq!(dsc(&ps, "Pages"), ["1"]); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 715abd2d..e8fc806f 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -26,13 +26,16 @@ //! - `scene`: the document walk that writes the page. //! - `tiff`: the TIFF preview. //! - `import`: the PostScript interpreter. +//! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`. mod import; +mod print; mod ps; mod scene; mod tiff; pub use import::{Imported, family_style, import}; +pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; use vectorcraft_geom::{Point, Rect}; @@ -244,6 +247,25 @@ const THUMBNAIL: &str = "thumbnail"; /// Largest native document [`native`] inflates (bytes). const MAX_NATIVE: u64 = 1 << 30; +/// The DSC comments every file of ours has after its version line: creator, title, date, +/// language level, data, and the spot colours `custom` with their CMYK equivalents. +pub(crate) fn comments(s: &mut String, title: &str, created: Option, level: Level, custom: &[(String, [f32; 4])]) { + use std::fmt::Write; + let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%Title: {}", ps::string(title)); + if let Some(t) = created { + let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t); + let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)"); + } + let _ = writeln!(s, "%%LanguageLevel: {}", level.id()); + s.push_str("%%DocumentData: Clean7Bit\n"); + for (i, (name, cmyk)) in custom.iter().enumerate() { + let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name)); + let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); + let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name)); + } +} + /// The PostScript program: header comments, thumbnail, prolog, setup, the page and the trailer /// with the native document. fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4])], thumb: Option<&[u8]>) -> String { @@ -252,21 +274,9 @@ fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4] let bb = bounding_box(o.region, o.origin); let hi = hires_box(o.region, o.origin); s.push_str("%!PS-Adobe-3.0 EPSF-3.0\n"); // brand-ok: the DSC version line every EPS file starts with - let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); - let _ = writeln!(s, "%%Title: {}", ps::string(&o.title)); - if let Some(t) = o.created { - let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t); - let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)"); - } + comments(&mut s, &o.title, o.created, o.level, custom); let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", bb[0], bb[1], bb[2], bb[3]); let _ = writeln!(s, "%%HiResBoundingBox: {} {} {} {}", ps::num(hi[0]), ps::num(hi[1]), ps::num(hi[2]), ps::num(hi[3])); - let _ = writeln!(s, "%%LanguageLevel: {}", o.level.id()); - s.push_str("%%DocumentData: Clean7Bit\n"); - for (i, (name, cmyk)) in custom.iter().enumerate() { - let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name)); - let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); - let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name)); - } s.push_str("%%Pages: 1\n%%EndComments\n"); if let Some(png) = thumb { data_section(&mut s, THUMBNAIL, png); diff --git a/crates/eps/src/print/mod.rs b/crates/eps/src/print/mod.rs new file mode 100644 index 00000000..6fe7c657 --- /dev/null +++ b/crates/eps/src/print/mod.rs @@ -0,0 +1,164 @@ +//! Print to a PostScript file: the sheets a print job laid out ([`PrintPage`]), each drawn by the +//! document walk the EPS writer uses, in one DSC-conforming file: a page per sheet with its paper +//! size, the ink and halftone screen of a separation, the flatness, and film negatives. + +use std::fmt::Write; + +use kurbo::Shape; +use vectorcraft_doc::{Document, Node}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::scene::{Page, Scene}; +use crate::{EpsOptions, EpsOutput, Level, comments, ps}; + +/// One sheet of a print job (see the PDF writer's print plan for how the spaces relate). +#[derive(Clone, Debug)] +pub struct PrintPage<'a> { + /// The document the sheet draws: the job's, or a separation's plate. + pub doc: &'a Document, + /// The page size in points. + pub size: (f64, f64), + /// Drawing space → page space (y down from the top-left corner). + pub view: Affine, + /// The part of the drawing space that shows (a tile): art and marks are clipped to it. + pub window: Option, + /// Document space → drawing space. + pub place: Affine, + /// The art drawn, in document space: what lies outside it is left out. + pub area: Rect, + /// Printer's marks, in drawing space. + pub marks: Option<&'a Node>, + /// A film negative: paper black, ink clear. + pub negative: bool, + /// A separation's ink: its name, screen ruling (lpi) and angle (degrees). + pub ink: Option<(&'a str, f64, f64)>, +} + +/// The settings of a whole job. +#[derive(Clone, Debug, Default)] +pub struct PrintJob { + pub level: Level, + /// `%%Title`. + pub title: String, + /// `%%CreationDate` as Unix seconds (UTC). + pub created: Option, + /// The flatness curves are drawn with (device pixels); `None`: the device's own. + pub flatness: Option, +} + +/// Every number of `a` finite? +fn finite(a: Affine) -> bool { + a.as_coeffs().iter().all(|v| v.is_finite()) +} + +/// The program that draws sheet `p` (its page setup and content, without the DSC page comment), +/// and what its walks found. +fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { + let (w, h) = p.size; + let r = p.area; + if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { + return Err(format!("page size {w} × {h}")); + } + if !finite(p.view) || !finite(p.place) { + return Err("a page's placement is not finite".into()); + } + let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() }; + let art_opts = opts(r); + let art = Scene::new(p.doc, &art_opts).run(); + // The marks' area: the drawing space the page shows (within the tile). + let paper = Rect::new(0.0, 0.0, w, h); + let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper }; + let shown = p.window.map_or(shown, |t| t.intersect(shown)); + let marks_opts = opts(shown); + let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m)); + let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024); + let (wi, hi) = (w.ceil() as i64, h.ceil() as i64); + let _ = writeln!(s, "%%PageBoundingBox: 0 0 {wi} {hi}"); + if let Some((name, _, _)) = p.ink { + let _ = writeln!(s, "%%PlateColor: {name}"); + } + s.push_str("%%BeginPageSetup\n"); + let _ = writeln!(s, "/setpagedevice where {{pop << /PageSize [{} {}] >> setpagedevice}} if", ps::num(w), ps::num(h)); + s.push_str("%%EndPageSetup\nVCdict begin\nq\n"); + if p.negative { + // Ink becomes clear and paper black: the inverted transfer paints the paper's white black. + let _ = writeln!(s, "{{1 exch sub}} settransfer 1 g 0 0 {} {} rectfill", ps::num(w), ps::num(h)); + } + if let Some((_, lpi, angle)) = p.ink { + // A round dot. + let _ = writeln!(s, "{} {} {{dup mul exch dup mul add 1 exch sub}} setscreen", ps::num(lpi), ps::num(angle)); + } + if let Some(f) = job.flatness { + let _ = writeln!(s, "{} setflat", ps::num(f)); + } + // Page space (y down) onto the paper (y up), then the drawing space. + let _ = writeln!(s, "[1 0 0 -1 0 {}] cm", ps::num(h)); + let _ = writeln!(s, "{} cm", ps::matrix(p.view)); + if let Some(t) = p.window { + ps::push_path(&mut s, &t.to_path(0.1)); + s.push_str("W\n"); + } + s.push_str("q\n"); + let _ = writeln!(s, "{} cm", ps::matrix(p.place)); + ps::push_path(&mut s, &r.to_path(0.1)); + s.push_str("W\n"); + s.push_str(&art.setup); + s.push_str(&art.body); + s.push_str("Q\n"); + let mut found = vec![art]; + if let Some(m) = marks { + // The marks' spot colour spaces are defined after the art is drawn: names may be reused. + s.push_str(&m.setup); + s.push_str(&m.body); + found.push(m); + } + s.push_str("Q\nend\nshowpage\n%%PageTrailer\n"); + Ok((s, found)) +} + +/// Write the sheets `pages` in `order` (indices into `pages`; copies repeat them) as one +/// PostScript file of `order.len()` pages. Each sheet is drawn once and repeated for its copies. +pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result { + if order.is_empty() { + return Err("nothing to print".into()); + } + let mut drawn = Vec::with_capacity(pages.len()); + let mut custom: Vec<(String, [f32; 4])> = vec![]; + let mut warnings: Vec = vec![]; + for p in pages { + let (s, found) = sheet(p, job)?; + for page in found { + for c in page.custom { + if !custom.iter().any(|(n, _)| *n == c.0) { + custom.push(c); + } + } + for w in page.warnings { + if !warnings.contains(&w) { + warnings.push(w); + } + } + } + drawn.push(s); + } + let (w, h) = pages.iter().fold((0.0f64, 0.0f64), |(w, h), p| (w.max(p.size.0), h.max(p.size.1))); + let size: usize = order.iter().filter_map(|i| drawn.get(*i)).map(String::len).sum(); + let mut s = String::with_capacity(size + 4096); + s.push_str("%!PS-Adobe-3.0\n"); // brand-ok: the DSC version line every conforming file starts with + comments(&mut s, &job.title, job.created, job.level, &custom); + let _ = writeln!(s, "%%BoundingBox: 0 0 {} {}", w.ceil() as i64, h.ceil() as i64); + let _ = writeln!(s, "%%Pages: {}\n%%PageOrder: Ascend\n%%EndComments", order.len()); + s.push_str("%%BeginProlog\n"); + s.push_str(ps::PROLOG); + s.push_str("%%EndProlog\n%%BeginSetup\n%%EndSetup\n"); + for (n, i) in order.iter().enumerate() { + let page = drawn.get(*i).ok_or("a page out of range")?; + let _ = writeln!(s, "%%Page: {0} {0}", n + 1); + s.push_str(page); + } + s.push_str("%%Trailer\n%%EOF\n"); + Ok(EpsOutput { bytes: s.into_bytes(), warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/print/tests.rs b/crates/eps/src/print/tests.rs new file mode 100644 index 00000000..171bb770 --- /dev/null +++ b/crates/eps/src/print/tests.rs @@ -0,0 +1,85 @@ +//! PostScript print jobs: DSC page comments, the setup of each sheet, and a file the PostScript +//! reader reads back. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use super::*; + +/// A 200 × 100 pt document with a red rectangle. +fn doc() -> Document { + let mut d = Document::new(200.0, 100.0); + let layer = d.layers[0].id; + let look = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 60.0)), look); + d.insert(Some(layer), 0, n).unwrap(); + d +} + +/// The document on a letter page, 36 pt in from its top-left corner. +fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> { + PrintPage { + doc: d, + size: (612.0, 792.0), + view: Affine::IDENTITY, + window: None, + place: Affine::translate((36.0, 36.0)), + area: d.artboards[0].rect, + marks, + negative: false, + ink: None, + } +} + +fn text(out: &EpsOutput) -> String { + String::from_utf8(out.bytes.clone()).unwrap() +} + +#[test] +fn pages_follow_dsc_and_repeat_for_copies() { + let d = doc(); + let mark = + Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25)); + let pages = [page(&d, None), PrintPage { size: (792.0, 612.0), ..page(&d, Some(&mark)) }]; + let job = PrintJob { title: "Job".into(), created: Some(0), ..PrintJob::default() }; + let out = print(&pages, &[0, 1, 0, 1], &job).unwrap(); + let ps = text(&out); + assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n")); + let dsc = |key: &str| ps.lines().filter_map(|l| l.strip_prefix(key)).map(str::trim).collect::>(); + assert_eq!(dsc("%%Pages:"), ["4"]); + assert_eq!(dsc("%%Page:"), ["1 1", "2 2", "3 3", "4 4"]); + assert_eq!(dsc("%%PageBoundingBox:"), ["0 0 612 792", "0 0 792 612", "0 0 612 792", "0 0 792 612"]); + assert_eq!(dsc("%%BoundingBox:"), ["0 0 792 792"]); + assert_eq!(dsc("%%LanguageLevel:"), ["3"]); + assert_eq!(ps.matches("showpage").count(), 4); + assert!(ps.contains("<< /PageSize [792 612] >> setpagedevice")); + // The marks are drawn in Registration on the pages that have them. + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 2, "{ps}"); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + // The PostScript reader reads the first page back: the rectangle 36 pt in, clipped to the + // artboard. + let back = crate::import(&out.bytes).unwrap(); + let art = back.document.layers[0].children().unwrap().clone(); + let clipped = art[0].children().unwrap(); + assert_eq!(clipped[0].geometric_bounds().unwrap(), Rect::new(36.0, 36.0, 236.0, 136.0)); + assert_eq!(clipped[1].geometric_bounds().unwrap(), Rect::new(56.0, 56.0, 156.0, 96.0)); + assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings); +} + +#[test] +fn separations_screens_flatness_negatives_and_tiles() { + let d = doc(); + let sheet = PrintPage { negative: true, ink: Some(("Cyan", 85.0, 15.0)), window: Some(Rect::new(0.0, 0.0, 100.0, 100.0)), ..page(&d, None) }; + let job = PrintJob { level: Level::Two, flatness: Some(3.0), ..PrintJob::default() }; + let ps = text(&print(&[sheet], &[0], &job).unwrap()); + assert!(ps.contains("%%PlateColor: Cyan\n")); + assert!(ps.contains("85 15 {dup mul exch dup mul add 1 exch sub} setscreen")); + assert!(ps.contains("{1 exch sub} settransfer 1 g 0 0 612 792 rectfill")); + assert!(ps.contains("3 setflat")); + assert!(ps.contains("%%LanguageLevel: 2")); + // The tile clips the page. + assert!(ps.contains("0 0 m\n100 0 l\n100 100 l\n0 100 l\n"), "{ps}"); + assert!(print(&[], &[], &job).is_err()); + assert!(print(&[PrintPage { size: (f64::NAN, 1.0), ..page(&d, None) }], &[0], &job).is_err()); +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 136c9ec1..bef20136 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -132,6 +132,16 @@ impl<'a> Scene<'a> { for l in &doc.layers { self.node(l, false); } + self.page() + } + + /// Write one object (art outside the document: a print job's marks) instead of the layers. + pub fn run_node(mut self, n: &Node) -> Page { + self.node(n, false); + self.page() + } + + fn page(self) -> Page { let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect(); Page { setup: self.setup, body: self.body, custom, warnings: self.warnings } } diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 1655e884..3e0d1a97 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -15,7 +15,8 @@ //! 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. +//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other +//! writers (PostScript), which can write screens and flatness themselves. mod layout; mod plates; @@ -24,6 +25,7 @@ mod settings; use std::borrow::Cow; use std::sync::Arc; +use kurbo::{Affine, Rect}; use serde::Serialize; use vectorcraft_doc::marks::PrinterMarks; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; @@ -51,6 +53,9 @@ pub struct PrintOptions { pub created: Option, /// Content streams uncompressed (readable operators, for tests and debugging). pub uncompressed: bool, + /// The job is written as PostScript, which carries halftone screens and flatness: no + /// warnings that they are left to the device. + pub postscript: bool, } /// A printed job. @@ -141,7 +146,7 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { } impl<'a> Job<'a> { - fn new(doc: &'a Document, set: &PrintSettings) -> Result { + fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; // Live effects print as their result (raster effects are rendered by the app first). let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); @@ -163,13 +168,13 @@ impl<'a> Job<'a> { 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()) { + if !postscript && 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 { + if !postscript && !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 }) @@ -180,13 +185,14 @@ impl<'a> Job<'a> { self.inks.get(*self.plates.get(plate?)?) } - /// Every page, copies included, in print order: (layout, ink). - fn pages(&self, set: &PrintSettings) -> Vec<(usize, Option)> { + /// Every page, copies included, in print order: indices into `sheets`. + fn order(&self, set: &PrintSettings) -> Vec { let copies = set.copies.max(1) as usize; - let mut v: Vec<_> = if set.collate { - (0..copies).flat_map(|_| self.sheets.iter().copied()).collect() + let sheets = 0..self.sheets.len(); + let mut v: Vec = if set.collate { + (0..copies).flat_map(|_| sheets.clone()).collect() } else { - self.sheets.iter().flat_map(|s| std::iter::repeat_n(*s, copies)).collect() + sheets.flat_map(|s| std::iter::repeat_n(s, copies)).collect() }; if set.reverse { v.reverse(); @@ -197,7 +203,7 @@ impl<'a> Job<'a> { /// What printing `doc` with `set` makes: pages, tiles, inks and warnings. pub fn preview(doc: &Document, set: &PrintSettings) -> Result { - let job = Job::new(doc, set)?; + let job = Job::new(doc, set, false)?; let sheets = job .sheets .iter() @@ -220,61 +226,136 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result, + /// 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, + /// Printer's marks, in drawing space (separated for the sheet's ink). + pub marks: Option, + /// Invert the page: paper black, ink clear (a film negative). + pub negative: bool, + /// The plate the sheet prints ([`PrintPlan::plates`]); `None`: composite. + pub plate: Option, + /// The ink of a separation, with its screen. + pub ink: Option, +} + +impl PlannedSheet { + fn sheet(&self) -> Sheet<'_> { + Sheet { + size: self.size, + trim: self.trim, + bleed: self.bleed, + view: self.view, + window: self.window, + place: self.place, + area: self.area, + clip: true, + marks: self.marks.as_ref(), + negative: self.negative, + } + } +} + +/// A laid-out job: the documents it draws, its sheets and the order they print in. +#[derive(Debug)] +pub struct PrintPlan<'a> { + /// The document as it prints: live effects applied, the layers that print. + pub doc: Cow<'a, Document>, + /// With separations, one grey document per ink that prints (its coverage). + pub plates: Vec, + /// One copy's sheets. + pub sheets: Vec, + /// Every page in print order, copies included: indices into `sheets`. + pub order: Vec, + pub title: String, + /// When the job ran (Unix seconds, UTC). + pub created: Option, + pub warnings: Vec, +} + +/// Lay out printing `doc` as `opts` say, for a writer to draw. +pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, PdfError> { + let set = &opts.settings; + let job = Job::new(doc, set, opts.postscript)?; + let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone()); + let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix); + let sep = Separator::new(&job.doc, set); + let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); + let marks = set.marks.printer_marks(); + let sheets = job + .sheets + .iter() + .filter_map(|(l, plate)| { + let l = job.layouts.get(*l)?; + let ink = job.ink(*plate); + let art = marks_art(&job.doc, &marks, l, ink, &title, created).map(|mut art| { + if let Some(ink) = ink { + sep.node(&ink.name, &mut art); + } + art + }); + Some(PlannedSheet { + size: l.size, + trim: l.page_trim, + bleed: l.page_bleed, + view: l.view, + window: l.window, + place: l.place, + area: l.area, + marks: art, + negative: set.output.image == PrintImage::Negative, + plate: *plate, + ink: ink.cloned(), + }) + }) + .collect(); + let order = job.order(set); + Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) +} + /// Print `doc` to a PDF as `opts` say. pub fn print(doc: &Document, opts: &PrintOptions) -> Result { - let set = &opts.settings; - let job = Job::new(doc, set)?; - let doc = &*job.doc; + let plan = plan(doc, opts)?; + let doc = &*plan.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)?; + let separations = opts.settings.output.mode == OutputMode::Separations; + let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?; // One exporter per ink (one for composite), each drawing its own document. - let sep = Separator::new(doc, set); - let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); let mut exporters: Vec = - if separations { plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] }; + if separations { plan.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; + ex.intent = opts.settings.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 &i in &plan.order { + let Some(s) = plan.sheets.get(i) else { continue }; + let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue }; + w.page(ex, &s.sheet())?; } for ex in exporters { w.absorb(ex); } let (bytes, more) = w.finish()?; - let mut warnings = job.warnings; + let mut warnings = plan.warnings; for m in more { if !warnings.contains(&m) { warnings.push(m); } } - Ok(PrintReport { bytes, pages: pages.len(), warnings }) + Ok(PrintReport { bytes, pages: plan.order.len(), warnings }) } /// The printer's marks of page `l` (`None` without marks), with its page information: the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6523394e..9765f498 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,8 @@ mod tests_pdfoutput; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_printps; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7d4f958f..2e6d1be2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", ), ( "print.printers", diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 71e134fd..8c92e45a 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -1,11 +1,13 @@ //! Printing through the platform. The host app installs a [`PrintService`] ([`crate::Services`]): //! the desktop hands the job to the system's print spooler, the web to the browser's print dialog. //! `file.print` with settings sends the engine's print-ready PDF (`file.print` in the engine) to -//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; -//! `print.printers` lists the printers. +//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; a +//! PostScript file (`format: "postscript"`, or a `.ps` path) is always saved. `print.printers` +//! lists the printers. use serde::Serialize; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; use crate::{VectorcraftApp, io}; @@ -50,19 +52,28 @@ pub fn printers(app: &mut VectorcraftApp) -> Value { /// `file.print` with settings: print the active document with `settings` (over the document's) /// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked -/// file, the web downloads it) with `toFile`, a `path` or no print service. +/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file +/// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved +/// the same way. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); - let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some(); + let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps"); + let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript; let mut q = json!({}); - if let Some(set) = p.get("settings").filter(|v| !v.is_null()) { - q["settings"] = set.clone(); + for k in ["settings", "format", "level", "flattenerPreset"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + q[k] = v.clone(); + } + } + if postscript { + // The path is taken off before the engine runs: it can't tell the format from it. + q["format"] = json!("postscript"); } if to_file || app.services.print.is_none() { if let Some(path) = s("path") { q["path"] = json!(path); } - let (path, mut v) = io::run_to_file(app, "file.print", "pdf", q)?; + let (path, mut v) = io::run_to_file(app, "file.print", if postscript { "ps" } else { "pdf" }, q)?; io::report_saved(app, &path, &v); if !to_file { app.ui.status.push_str(" (printing isn't available here: the job was saved as a PDF)"); diff --git a/crates/ui-egui/src/tests_printps.rs b/crates/ui-egui/src/tests_printps.rs new file mode 100644 index 00000000..2814c565 --- /dev/null +++ b/crates/ui-egui/src/tests_printps.rs @@ -0,0 +1,65 @@ +//! Print to a PostScript file through the app: the job is saved, never sent to the printer. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::print::{PrintJob, PrintService, Printer}; +use crate::{Services, VectorcraftApp}; + +/// What the app wrote: (path, bytes). +type Written = Rc)>>>; + +/// A printer that counts the jobs it is sent. +struct Counting(Rc>); + +impl PrintService for Counting { + fn printers(&mut self) -> Vec { + vec![Printer { name: "Office".into(), default: true }] + } + + fn print(&mut self, _: &PrintJob) -> Result { + *self.0.borrow_mut() += 1; + Ok("Sent".into()) + } +} + +#[test] +fn postscript_is_saved_not_printed() { + let sent = Rc::new(RefCell::new(0)); + let written: Written = Rc::default(); + let w = written.clone(); + let services = Services { + print: Some(Box::new(Counting(sent.clone()))), + pick_save: Some(Box::new(|pick: &crate::FilePick| Some(format!("/tmp/{}", pick.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80, "artboards": 2})).unwrap(); + // A .ps path: PostScript, at the level asked for. + let r = app.run("file.print", json!({"path": "/tmp/job.ps", "level": 2})).unwrap(); + assert_eq!((r["format"].clone(), r["printed"].clone(), r["pages"].clone()), (json!("postscript"), json!(false), json!(2)), "{r}"); + // No path: a picked .ps file. + let r = app.run("file.print", json!({"format": "postscript", "settings": {"copies": 2}})).unwrap(); + assert_eq!(r["pages"], 4); + let w = written.borrow(); + assert_eq!(w.len(), 2); + assert_eq!(w[0].0, "/tmp/job.ps"); + assert!(w[1].0.ends_with(".ps"), "{}", w[1].0); + for (_, bytes) in w.iter() { + assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n")); + } + assert!(String::from_utf8_lossy(&w[0].1).contains("%%LanguageLevel: 2")); + assert_eq!(*sent.borrow(), 0, "nothing went to the printer"); + drop(w); + // A PDF still goes to the printer. + app.run("file.print", json!({"settings": {"copies": 1}})).unwrap(); + assert_eq!(*sent.borrow(), 1); + assert!(app.run("file.print", json!({"format": "ps2"})).is_err()); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5045b53a..6b57c740 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1306,6 +1306,18 @@ as they do for art larger than the imageable area and for overprints in composit {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}} ``` +Print to a PostScript file: `file.print {format: "postscript"}` (the default for a `.ps` path) writes the same job as +PostScript, `level: 3` (default) or `2`, answering `format: "postscript"` (`"pdf"` otherwise; in the app the job is +saved, not sent to a printer). The file follows the +DSC: `%%Pages` is the page count (copies included), each `%%Page` sets its paper size, separations name their ink +(`%%PlateColor`) and set its halftone screen (`frequency`, `angle`, a round dot), a fixed flatness is written +(`setflat`), negatives invert the transfer, and the marks print in Registration. PostScript has no transparency: it +is flattened first with `flattenerPreset` (`medium` by default; `high`, `low` or a saved one), with a warning. + +```json +{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.ps","level":2,"settings":{"output":{"mode":"separations"}}}}} +``` + ## Data Recovery Modified documents get recovery copies (`file.recovery.save`; the app runs it every `autosaveInterval` minutes while