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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 17:01:36 +07:00
co-authored by Claude Opus 5.5
parent 6752b22783
commit 0428e56342
15 changed files with 683 additions and 82 deletions
+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"]);
}