M4.92: Print to a PostScript file

file.print {format: "postscript"} (the default for a .ps path) writes the
print job as Level 3 or 2 (level) PostScript instead of a PDF: DSC comments
with %%Pages the page count, one page per sheet with its paper size,
printer's marks in Registration, separations naming their ink (%%PlateColor)
with its halftone screen, a fixed flatness and film negatives. Transparency
is flattened first with flattenerPreset. The answer says which format was
written. In the app, a PostScript job is always saved, never sent to the
printer (the Print dialog's own options are unchanged).

The PDF print's layout is exposed as vectorcraft_pdf::plan (the PDF writer
uses it too); vectorcraft_eps::print draws its sheets with the EPS writer's
document walk, each sheet once however many copies print.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 17:01:36 +07:00
1 parent 6752b22783
commit 0428e56342
15 files changed
+683 -82

No files matched your search

+1 -1
View File
@@ -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(),
+1 -4
View File
@@ -152,10 +152,7 @@ fn parse(p: &Value) -> Result<Settings> {
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")?,
+75 -9
View File
@@ -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<CommandSpec> {
"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<Value> {
Ok(v)
}
const PRINT: &str = "file.print";
/// Whether `file.print` writes PostScript: `format`, else a `.ps` path.
fn to_postscript(p: &Value) -> Result<bool> {
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<Value> {
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<PrintPage> = 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 }))
}
+2
View File
@@ -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;
+96
View File
@@ -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<String> {
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"]);
}
+23 -13
View File
@@ -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<i64>, 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);
+164
View File
@@ -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<Rect>,
/// 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<i64>,
/// The flatness curves are drawn with (device pixels); `None`: the device's own.
pub flatness: Option<f64>,
}
/// 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<Page>), 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<EpsOutput, String> {
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<String> = 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;
+85
View File
@@ -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::<Vec<_>>();
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());
}
+10
View File
@@ -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 }
}
+128 -47
View File
@@ -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<i64>,
/// 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<Arc<Node>>, which: PrintLayers) {
}
impl<'a> Job<'a> {
fn new(doc: &'a Document, set: &PrintSettings) -> Result<Self, PdfError> {
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> 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);
@@ -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<usize>)> {
/// Every page, copies included, in print order: indices into `sheets`.
fn order(&self, set: &PrintSettings) -> Vec<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()
let sheets = 0..self.sheets.len();
let mut v: Vec<usize> = 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<PrintPreview, PdfError> {
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<PrintPreview, PdfE
Ok(PrintPreview { pages: job.sheets.len() * set.copies.max(1) as usize, sheets, tiles: job.tiles, inks: job.inks, warnings: job.warnings })
}
/// One sheet of a laid-out job, ready for a writer (PDF here, PostScript in the EPS writer).
#[derive(Clone, Debug)]
pub struct PlannedSheet {
/// The page 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,
/// Printer's marks, in drawing space (separated for the sheet's ink).
pub marks: Option<Node>,
/// 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<usize>,
/// The ink of a separation, with its screen.
pub ink: Option<PrintInk>,
}
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<Document>,
/// One copy's sheets.
pub sheets: Vec<PlannedSheet>,
/// Every page in print order, copies included: indices into `sheets`.
pub order: Vec<usize>,
pub title: String,
/// When the job ran (Unix seconds, UTC).
pub created: Option<i64>,
pub warnings: Vec<String>,
}
/// Lay out printing `doc` as `opts` say, for a writer to draw.
pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>, 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<Document> = (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<PrintReport, PdfError> {
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<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)] };
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
+2
View File
@@ -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;
+1 -1
View File
@@ -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",
+18 -7
View File
@@ -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<Value, String> {
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)");
+65
View File
@@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
/// A printer that counts the jobs it is sent.
struct Counting(Rc<RefCell<usize>>);
impl PrintService for Counting {
fn printers(&mut self) -> Vec<Printer> {
vec![Printer { name: "Office".into(), default: true }]
}
fn print(&mut self, _: &PrintJob) -> Result<String, String> {
*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());
}
+12
View File
@@ -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