mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
6752b22783
commit
0428e56342
15 files changed
+683
-82
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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")?,
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user