From 6752b2278375f051376b4f1e6403a5cd4bef7f96 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 16:27:00 +0700 Subject: [PATCH 1/2] M4.91: EPS and PostScript files open and place document.open and file.place read .eps files and PostScript by any name (PostScript .ai opens as artwork, .ait as a new untitled document). An EPS file VectorCraft wrote restores the document it carries (restored: true); other files run through a small clean-room PostScript interpreter (paths, colours and spot inks, gsave/grestore, concat, line state, bind def, procedures, loops and dictionaries, clips, shadings, images through the decoding filters, type as point type), and fall back to their TIFF preview with a warning when the program can't be read. Operation, nesting, memory and path budgets keep hostile files from hanging or exhausting memory. The clipping-group nesting the metafile reader used moves to vectorcraft_doc::clipnest, shared by both readers. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/clipnest.rs | 66 + crates/doc/src/lib.rs | 1 + crates/engine/src/cmd/fileio/load.rs | 18 +- crates/engine/src/cmd/fileio/mod.rs | 9 +- crates/engine/src/cmd/fileio/tests_eps.rs | 3 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 121 ++ .../engine/src/cmd/fileio/tests_pdfimport.rs | 4 +- crates/engine/src/cmd/place/mod.rs | 17 +- crates/engine/tests/import_fuzz.rs | 64 +- crates/eps/src/import/data.rs | 703 ++++++++++ crates/eps/src/import/graphics.rs | 1197 +++++++++++++++++ crates/eps/src/import/interp.rs | 1111 +++++++++++++++ crates/eps/src/import/lex.rs | 289 ++++ crates/eps/src/import/mod.rs | 210 +++ crates/eps/src/import/obj.rs | 259 ++++ crates/eps/src/import/tests.rs | 454 +++++++ crates/eps/src/import/text.rs | 288 ++++ crates/eps/src/lib.rs | 14 + crates/metafile/src/import.rs | 51 +- docs/mcp.md | 27 +- 20 files changed, 4838 insertions(+), 68 deletions(-) create mode 100644 crates/doc/src/clipnest.rs create mode 100644 crates/engine/src/cmd/fileio/tests_epsimport.rs create mode 100644 crates/eps/src/import/data.rs create mode 100644 crates/eps/src/import/graphics.rs create mode 100644 crates/eps/src/import/interp.rs create mode 100644 crates/eps/src/import/lex.rs create mode 100644 crates/eps/src/import/mod.rs create mode 100644 crates/eps/src/import/obj.rs create mode 100644 crates/eps/src/import/tests.rs create mode 100644 crates/eps/src/import/text.rs diff --git a/crates/doc/src/clipnest.rs b/crates/doc/src/clipnest.rs new file mode 100644 index 00000000..ac8a4179 --- /dev/null +++ b/crates/doc/src/clipnest.rs @@ -0,0 +1,66 @@ +//! Clipped art read from page descriptions (metafiles, PostScript) into clipping groups. +//! +//! Importers record each object they draw with the chain of clips in force at the time, outermost +//! first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into one clipping +//! group, nesting groups for nested clips, keeping the drawing order. + +use std::sync::Arc; + +use vectorcraft_geom::{BezPath, FillRule, PathData}; + +use crate::appearance::Appearance; +use crate::{Document, Node, NodeKind}; + +/// A clip: a region in document space and its rule. Each has an id unique within an import, so +/// what one clip clips can be grouped. +#[derive(Debug)] +pub struct Clip { + pub id: u32, + pub path: BezPath, + pub rule: FillRule, +} + +/// Objects in drawing order, each with its clips (outermost first). +pub type Drawn = Vec<(Vec>, Node)>; + +/// The nodes of `items`, those under clips grouped into clipping groups (ids from `doc`), in +/// order. +pub fn nest(doc: &mut Document, items: Drawn) -> Vec> { + nest_at(doc, items, 0) +} + +/// [`nest`] for the clips at `depth` and deeper. +fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { + let mut out: Vec> = vec![]; + let mut group: Drawn = vec![]; + let mut clip: Option> = None; + for (chain, node) in items { + let here = chain.get(depth).cloned(); + if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) { + if let Some(c) = clip.take() { + out.push(clip_group(doc, &c, std::mem::take(&mut group), depth)); + } + clip = here.clone(); + } + match here { + Some(_) => group.push((chain, node)), + None => out.push(Arc::new(node)), + } + } + if let Some(c) = clip { + out.push(clip_group(doc, &c, group, depth)); + } + out +} + +/// A clipping group of `items`, clipped by `c` (their clip at `depth`). +fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc { + let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default()); + if let NodeKind::Path { clipping, rule, .. } = &mut path.kind { + *clipping = true; + *rule = c.rule; + } + let mut children = vec![Arc::new(path)]; + children.extend(nest_at(doc, items, depth + 1)); + Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true })) +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2e04dc6b..857f2c7c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,7 @@ pub mod appearance; pub mod assets; +pub mod clipnest; pub mod graph; pub mod hit; pub mod inks; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index b02ac1b4..7caa2e52 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -55,6 +55,11 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if vectorcraft_svg::is_svgz(bytes) { return format("svgz"); } + if vectorcraft_pdf::is_postscript(bytes) { + // PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait + // keep their meaning (a template), read as PostScript all the same. + return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps")); + } if bytes.starts_with(b"%PDF") { // .ai and .ait files are PDF inside; the extension keeps the template meaning. return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf")); @@ -124,11 +129,6 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { - // PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by - // any name. - if vectorcraft_pdf::is_postscript(bytes) { - return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript))); - } let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) { Some(u) => err(format!("can't open `{}`: {}", file_name(name), u.hint)), None => err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")), @@ -136,6 +136,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let title = file_name(name); let mut converted = false; let (mut doc, warnings, restored) = match format.id { + // EPS and PostScript .ai: the document our EPS files carry, else the PostScript read. + _ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => { + let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); + restore_or_import(editing, || { + let r = vectorcraft_eps::import(bytes).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + Ok((r.document, r.warnings)) + })? + } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index af0f5fe8..fd5ccd7d 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -546,7 +546,7 @@ pub const FORMATS: &[Format] = &[ label: "EPS", extensions: &["eps"], mime: "application/postscript", - read: false, + read: true, write: true, raster: false, options: eps::OPTIONS, @@ -578,6 +578,7 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "eps", ]; /// Text files: File → Place sets them as area type (Text Import Options). @@ -604,6 +605,7 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "eps", "txt", ]; @@ -901,3 +903,6 @@ mod tests_tiffbmp; #[cfg(test)] mod tests_psd; + +#[cfg(test)] +mod tests_epsimport; diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 0817aa9a..6e5c1df9 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -174,7 +174,8 @@ fn bad_eps_options_are_refused_and_formats_list_eps() { assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err()); let r = s.execute("document.formats", &json!({})).unwrap(); let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone(); - assert_eq!((eps["read"].as_bool(), eps["write"].as_bool(), eps["mime"].as_str()), (Some(false), Some(true), Some("application/postscript"))); + // Readable too since EPS import (tests_epsimport.rs). + assert_eq!((eps["read"].as_bool(), eps["write"].as_bool(), eps["mime"].as_str()), (Some(true), Some(true), Some("application/postscript"))); for o in [ "level", "previewFormat", diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs new file mode 100644 index 00000000..ad40585f --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -0,0 +1,121 @@ +//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry, +//! others are read by the PostScript interpreter or come in as their preview. + +use serde_json::{Value, json}; + +use super::tests_svgedit::{comparable, rich}; +use super::*; + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap() +} + +fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Result { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})) +} + +/// `bytes` with `from` replaced by `to` (the same length: the preview header's offsets stay). +fn patched(bytes: &[u8], from: &[u8], to: &[u8]) -> Vec { + assert_eq!(from.len(), to.len()); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from))); + let mut out = bytes.to_vec(); + out[at..at + to.len()].copy_from_slice(to); + out +} + +/// An EPS file of ours as another app would write it: without the document it carries. +fn foreign(eps: &[u8]) -> Vec { + patched(eps, b"%VectorCraft_BeginData: native", b"%VectorCraft_BeginData: nativx") +} + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +#[test] +fn an_eps_file_of_ours_reopens_as_the_same_document() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + let eps = b64(&s.execute("document.exportEps", &json!({})).unwrap()); + let r = open(&mut s, "rich.eps", &eps).unwrap(); + assert_eq!((&r["warnings"], &r["restored"], &r["format"]), (&json!([]), &json!(true), &json!("eps")), "{r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before, "the document came back exactly"); + assert_eq!(s.doc().unwrap().path, None, "an EPS file is not saved over"); + // A document that can't be read: the PostScript is read instead, with a warning. + let at = eps.windows(9).position(|w| w == b"native\n% ").unwrap() + 12; + let mut damaged = eps.clone(); + damaged[at] = b'z'; + let r = open(&mut s, "damaged.eps", &damaged).unwrap(); + assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_DAMAGED), &json!(false)), "{r}"); + assert!(objects(&s) > 0); +} + +#[test] +fn other_eps_files_are_read_by_the_postscript_interpreter() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "cmykPostScript": false})).unwrap())); + let r = open(&mut s, "plain.eps", &eps).unwrap(); + assert_eq!((&r["restored"], &r["format"]), (&json!(false), &json!("eps")), "{r}"); + let d = &s.doc().unwrap().doc; + // The page is the bounding box: the art's bounds. + assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points"); + assert_eq!(objects(&s), 1); + assert_eq!(s.doc().unwrap().title(), "plain.eps"); +} + +#[test] +fn an_unreadable_eps_file_comes_in_as_its_preview() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "tiffColor"})).unwrap())); + // An operator the interpreter doesn't know (another app's own). + let eps = patched(&eps, b"showpage", b"frobnica"); + let r = open(&mut s, "art.eps", &eps).unwrap(); + let w = r["warnings"][0].as_str().unwrap(); + assert!(w.contains("frobnica") && w.contains("preview"), "{r}"); + let d = &s.doc().unwrap().doc; + let layer = &d.layers[0]; + let image = &layer.children().unwrap()[0]; + assert!(matches!(image.kind, vectorcraft_doc::NodeKind::Image(_)), "{:?}", image.kind); + assert_eq!(image.geometric_bounds().unwrap(), d.artboards[0].rect); +} + +#[test] +fn postscript_ai_files_and_garbage() { + let mut s = Session::new(); + let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n"; + // A PostScript .ai opens as artwork; Save asks where (the file isn't written back). + let r = open(&mut s, "legacy.ai", ps).unwrap(); + assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + assert_eq!(objects(&s), 1); + // A .ait one opens as a new untitled document. + let r = open(&mut s, "legacy.ait", ps).unwrap(); + assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}"); + // Garbage named .eps says what it is. + let e = open(&mut s, "junk.eps", b"not postscript at all").unwrap_err().to_string(); + assert!(e.contains("junk.eps") && e.contains("PostScript"), "{e}"); + let e = open(&mut s, "junk.eps", &[0xC5, 0xD0, 0xD3, 0xC6, 1, 2, 3]).unwrap_err().to_string(); + assert!(e.contains("junk.eps"), "{e}"); +} + +#[test] +fn eps_files_place_as_one_group_clipped_to_their_bounding_box() { + let mut s = rich(); + let eps = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "artboards": [0]})).unwrap()); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let r = s.execute("file.place", &json!({"name": "rich.eps", "dataBase64": vectorcraft_format::base64_encode(&eps), "at": [300, 300]})).unwrap(); + assert_eq!(r["format"], "eps", "{r}"); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + // The interpreter's reading of the same file places too. + let r = s.execute("file.place", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}"); + let info = s.execute("file.place.info", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap(); + assert_eq!(info["format"], "eps"); + // EPS is readable, and listed for the open and place dialogs. + assert!(format("eps").is_some_and(|f| f.read && f.write)); + assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps")); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index 09437fab..35accace 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -149,7 +149,9 @@ fn load_options_read_pages_crop_and_password() { #[test] fn a_postscript_ai_file_says_it_cannot_be_opened_yet() { - let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 lineto stroke\nshowpage\n%%EOF\n"; + // PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read, + // with no preview to fall back on, still says so. + let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 frobnicate stroke\nshowpage\n%%EOF\n"; let mut s = Session::new(); for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] { let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string(); diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 54839c8b..c760001d 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -8,6 +8,9 @@ //! (`dxf`, [`fileio::dxfimport`]). //! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by //! its art (`crop: "bounding"`). +//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by +//! its art: the document an EPS file of ours carries, else what its PostScript draws, else its +//! preview ([`fileio::load`]). //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "bounding"`). @@ -50,7 +53,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -145,7 +148,8 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { { opts.dxf.fit_to = (b.width(), b.height()); } - let pdf = matches!(format.id, "pdf" | "ai" | "ait"); + // PostScript .ai files are read like EPS. + let pdf = matches!(format.id, "pdf" | "ai" | "ait") && !vectorcraft_pdf::is_postscript(&src.bytes); if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) { return Err(bad( cmd, @@ -164,7 +168,7 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { let (mut doc, warnings) = match format.id { // Only the page placed is read, its artboard the box asked for (Bounding Box: the art's // bounds, below). - "pdf" | "ai" | "ait" => { + "pdf" | "ai" | "ait" if pdf => { let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts }; fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))? } @@ -190,10 +194,9 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" }; bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) })?; - // A PDF import has one layer per page; a native document's art is whatever lies on the - // artboard. - let pages: Vec<&Arc> = - if matches!(format.id, "vectorcraft" | "template") { layers.iter().collect() } else { layers.get(index).into_iter().collect() }; + // A PDF import has one layer per page; a native document's (or an EPS file's) art is + // whatever lies on the artboard. + let pages: Vec<&Arc> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() }; let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); (nodes, crop.then_some(board)) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f395437a..b7c71a92 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3,7 +3,8 @@ //! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records -//! of every kind with random contents) opened, placed or pasted. +//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files +//! (damaged ones, hostile programs) read by the PostScript interpreter. //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -857,6 +858,67 @@ proptest! { let _ = vectorcraft_eps::thumbnail(&bytes); survive("mutated EPS file", || vectorcraft_eps::native(&bytes).and_then(|n| vectorcraft_format::load(&n).ok()))?; } + + /// Damaged EPS files without the document: the PostScript interpreter (or the preview + /// fallback) reads them as an error or a document. + #[test] + fn eps_mutated_postscript_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::()), 0..12)) { + static SAMPLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let mut bytes = SAMPLE.get_or_init(foreign_eps_sample).clone(); + for &(at, b) in &edits { + let n = bytes.len(); + bytes[at % n] = b; + } + bytes.truncate(cut.max(4)); + survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; + } + + /// Hostile PostScript programs: operators in any order with any operands, opened and placed. + #[test] + fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60)) { + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n%%EndComments\n{}\nshowpage\n", tokens.join(" ")); + survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?; + let r = catch_quiet(|| { + let mut s = rich_session(); + let _ = s.execute("file.place", &json!({"name": "x.eps", "dataBase64": vectorcraft_format::base64_encode(ps.as_bytes())})); + }); + r.map_err(|msg| TestCaseError::fail(format!("placing hostile PostScript panicked: {msg}")))?; + } +} + +/// [`eps_sample`] as another app would write it: without the document it carries, so its +/// PostScript is read. +fn foreign_eps_sample() -> Vec { + let mut bytes = eps_sample(); + let marker = b"%VectorCraft_BeginData: native"; + let at = bytes.windows(marker.len()).position(|w| w == marker).unwrap(); + bytes[at + marker.len() - 1] = b'x'; + bytes +} + +/// The operators and operands of a hostile PostScript program. +fn arb_ps_token() -> impl Strategy { + prop_oneof![ + arb_num(), + prop::sample::select(vec![ + "moveto", "lineto", "curveto", "rlineto", "rmoveto", "closepath", "fill", "eofill", "stroke", "clip", "eoclip", "newpath", + "gsave", "grestore", "save", "restore", "concat", "translate", "scale", "rotate", "setlinewidth", "setdash", "setgray", + "setrgbcolor", "setcmykcolor", "sethsbcolor", "setcolorspace", "setcolor", "setpattern", "makepattern", "shfill", "arc", "arcn", + "arct", "arcto", "rectfill", "rectclip", "rectstroke", "image", "imagemask", "colorimage", "currentfile", "filter", + "readhexstring", "readstring", "show", "stringwidth", "charpath", "findfont", "scalefont", "makefont", "setfont", "selectfont", + "def", "bind", "load", "dup", "exch", "pop", "roll", "index", "copy", "for", "repeat", "loop", "exit", "if", "ifelse", "exec", + "stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall", + "[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox", + "clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate", + "/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode", + "/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "", "<~87cURD]i,\"Ebo80~>", + "true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 ]", + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function << /FunctionType 2 /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>", + "<< /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceGray /Coords [0 0 0 9 9 9] /Function << /FunctionType 3 /Functions [] /Bounds [] /Encode [] >> >> >>", + "<< /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijkl) >>", + ]) + .prop_map(str::to_string), + ] } proptest! { diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs new file mode 100644 index 00000000..96ed2039 --- /dev/null +++ b/crates/eps/src/import/data.rs @@ -0,0 +1,703 @@ +//! Data the program reads: from itself (`currentfile`) through decoding filters, and images +//! sampled from it, from strings or from procedures. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::graphics::{Space, process}; +use super::interp::{Interp, matrix_of}; +use super::lex::{find, hex_decode}; +use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err}; +use crate::ps; + +/// Most bytes one filter or image reads. +const MAX_DATA: usize = 1 << 28; +/// Most pixels an image may have. +const MAX_PIXELS: usize = 1 << 26; +/// Most distinct colours an image converts through a tint transform. +const MAX_TINTS: usize = 1 << 16; + +/// A file object: the program itself, or data a filter decoded. +#[derive(Debug)] +pub(crate) enum Stream { + Current, + /// Filters over the program's own data, decoded when first read. + Pending(Vec), + Buf { + data: Vec, + pos: usize, + /// The samples are 8-bit RGB (or grey) whatever the image's colour space: a decoded JPEG. + rgb: Option, + }, +} + +fn io(at: &str) -> Res { + ps_err("ioerror", at) +} + +/// The bytes `RunLengthDecode` makes of `raw`, and how many it read. +fn run_length(raw: &[u8]) -> Res<(Vec, usize)> { + let mut out = vec![]; + let mut i = 0; + while let Some(&n) = raw.get(i) { + i += 1; + match n { + 128 => break, + 0..=127 => { + let len = usize::from(n) + 1; + out.extend_from_slice(raw.get(i..i + len).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?); + i += len; + } + _ => { + let b = *raw.get(i).ok_or(PsError::Ps("ioerror", "RunLengthDecode".into()))?; + i += 1; + out.extend(std::iter::repeat_n(b, 257 - usize::from(n))); + } + } + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + Ok((out, i)) +} + +/// zlib data inflated, and how many bytes of `raw` it took. +fn flate(raw: &[u8]) -> Res<(Vec, usize)> { + let mut z = flate2::Decompress::new(true); + let mut out = Vec::with_capacity(raw.len().saturating_mul(3).min(MAX_DATA)); + loop { + let before = (z.total_in(), z.total_out()); + if out.capacity() - out.len() < 64 * 1024 { + out.reserve(256 * 1024); + } + let input = raw.get(z.total_in() as usize..).unwrap_or_default(); + let status = z.decompress_vec(input, &mut out, flate2::FlushDecompress::None).map_err(|_| PsError::Ps("ioerror", "FlateDecode".into()))?; + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + match status { + flate2::Status::StreamEnd => break, + _ if (z.total_in(), z.total_out()) == before => break, + _ => {} + } + } + Ok((out, z.total_in() as usize)) +} + +/// LZW data (`EarlyChange` 1) decoded, and how many bytes of `raw` it took. +fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { + let mut table: Vec> = (0..=255u8).map(|b| vec![b]).collect(); + table.push(vec![]); + table.push(vec![]); + let (mut out, mut prev): (Vec, Option>) = (vec![], None); + let (mut acc, mut bits, mut width, mut i) = (0u32, 0u32, 9u32, 0usize); + loop { + while bits < width { + let Some(&b) = raw.get(i) else { return Ok((out, i)) }; + i += 1; + acc = (acc << 8) | u32::from(b); + bits += 8; + } + let code = ((acc >> (bits - width)) & ((1 << width) - 1)) as usize; + bits -= width; + match code { + 256 => { + table.truncate(258); + width = 9; + prev = None; + } + 257 => return Ok((out, i)), + _ => { + let entry = match (table.get(code), &prev) { + (Some(e), _) if !e.is_empty() => e.clone(), + (_, Some(p)) if code == table.len() => { + let mut e = p.clone(); + e.extend(p.first()); + e + } + _ => return io("LZWDecode"), + }; + out.extend_from_slice(&entry); + if let Some(mut p) = prev.take() + && table.len() < 4096 + { + p.extend(entry.first()); + table.push(p); + } + width = match table.len() + 1 { + n if n >= 2048 => 12, + n if n >= 1024 => 11, + n if n >= 512 => 10, + _ => 9, + }; + prev = Some(entry); + if out.len() > MAX_DATA { + return Err(PsError::Limit("decoded data is too large")); + } + } + } + } +} + +/// A JPEG's pixels (grey or RGB, 8 bits) with its channel count, and how many bytes of `raw` it +/// took. +fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { + let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); + let img = image::load_from_memory_with_format(raw.get(..end).unwrap_or_default(), image::ImageFormat::Jpeg) + .map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; + if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } +} + +/// A decoding filter and its parameters. +#[derive(Clone, Debug)] +pub(crate) struct Filter { + name: Rc, + /// `SubFileDecode`'s count and end-of-data string. + sub: (usize, Vec), +} + +/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG. +fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec, usize, Option)> { + let (data, used) = match &*f.name { + "ASCIIHexDecode" => { + let end = raw.iter().position(|b| *b == b'>'); + let body = raw.get(..end.unwrap_or(raw.len())).unwrap_or_default(); + (hex_decode(body).ok_or(PsError::Ps("ioerror", "ASCIIHexDecode".into()))?, end.map_or(raw.len(), |e| e + 1)) + } + "ASCII85Decode" => { + let end = find(raw, b"~>"); + let mut text = String::from_utf8_lossy(raw.get(..end.unwrap_or(raw.len())).unwrap_or_default()).into_owned(); + text.push_str("~>"); + (ps::ascii85_decode(&text).ok_or(PsError::Ps("ioerror", "ASCII85Decode".into()))?, end.map_or(raw.len(), |e| e + 2)) + } + "RunLengthDecode" => run_length(raw)?, + "FlateDecode" => flate(raw)?, + "LZWDecode" => lzw(raw)?, + "DCTDecode" => { + let (d, used, channels) = dct(raw)?; + return Ok((d, used, Some(channels))); + } + "SubFileDecode" => { + let (count, eod) = &f.sub; + if eod.is_empty() { + let n = (*count).min(raw.len()); + (raw.get(..n).unwrap_or_default().to_vec(), n) + } else { + let (mut at, mut seen) = (0, 0); + loop { + match find(raw.get(at..).unwrap_or_default(), eod) { + Some(i) if seen < *count => { + seen += 1; + at += i + eod.len(); + } + Some(i) => break (raw.get(..at + i).unwrap_or_default().to_vec(), at + i + eod.len()), + None => break (raw.to_vec(), raw.len()), + } + } + } + } + "NullEncode" => (raw.to_vec(), raw.len()), + other => return ps_err("undefined", other), + }; + Ok((data, used, None)) +} + +/// What a source of image data gives. +enum Source { + File(Rc>), + Str(Vec), + Proc(Obj), +} + +impl Interp<'_> { + pub fn data_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + CurrentFile => self.push(Obj::File(Rc::new(RefCell::new(Stream::Current))))?, + Filter => self.filter()?, + ReadHexString | ReadString => { + let s = self.pop_str()?; + let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.borrow().len(); + let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? }; + let full = got.len() == n; + self.alloc(got.len())?; + if let Some(d) = s.borrow_mut().get_mut(..got.len()) { + d.copy_from_slice(&got); + } + self.push(Obj::string(got))?; + self.push(Obj::Bool(full))?; + } + ReadLine => { + let s = self.pop_str()?; + let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") }; + let n = s.borrow().len(); + let mut line = vec![]; + let mut ended = false; + while line.len() < n { + match self.read(&f, 1)?.first() { + Some(b'\n') | None => { + ended = true; + break; + } + Some(b'\r') => { + ended = true; + break; + } + Some(b) => line.push(*b), + } + } + self.alloc(line.len())?; + self.push(Obj::string(line))?; + self.push(Obj::Bool(ended))?; + } + FlushFile | CloseFile => { + self.pop()?; + } + File => { + self.pop()?; + self.pop()?; + return ps_err("invalidfileaccess", "file"); + } + Eexec => self.eexec()?, + Image | ImageMask | ColorImage => self.image(op)?, + _ => self.text_op(op)?, + } + Ok(()) + } + + /// Up to `n` bytes of file `f`. + fn read(&mut self, f: &Rc>, n: usize) -> Res> { + self.materialize(f)?; + Ok(match &mut *f.borrow_mut() { + Stream::Current => { + let rest = self.lex.data(); + let k = n.min(rest.len()); + let v = rest.get(..k).unwrap_or_default().to_vec(); + self.lex.advance(k); + v + } + Stream::Buf { data, pos, .. } => { + let start = (*pos).min(data.len()); + let end = start.saturating_add(n).min(data.len()); + *pos = end; + data.get(start..end).unwrap_or_default().to_vec() + } + Stream::Pending(_) => vec![], + }) + } + + /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). + fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { + let mut out = Vec::with_capacity(n); + let mut high: Option = None; + while out.len() < n { + let Some(&b) = self.read(f, 1)?.first() else { break }; + let Some(v) = char::from(b).to_digit(16) else { continue }; + match high.take() { + Some(h) => out.push(h << 4 | v as u8), + None => high = Some(v as u8), + } + } + Ok(out) + } + + /// `filter`: a file that decodes its source. Over the program's own data it decodes when + /// first read (the data follows the operator that reads it); over other data, at once. + fn filter(&mut self) -> Res { + let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?; + let sub = if &*name == "SubFileDecode" { + let eod = self.pop_str()?.borrow().clone(); + let count = self.pop_count()?; + (count, eod) + } else { + (0, vec![]) + }; + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + self.pop()?; + let predictor = d.borrow().get(&Key::name("Predictor")).and_then(Obj::as_num).unwrap_or(1.0); + if predictor > 1.0 { + return ps_err("undefined", "a filter predictor"); + } + } + let spec = Filter { name, sub }; + let stream = match self.pop()? { + Obj::File(f) => { + if let Stream::Buf { data, pos, .. } = &*f.borrow() { + self.alloc(data.len().saturating_sub(*pos))?; + } + let chain = match &*f.borrow() { + Stream::Current => Some(vec![]), + Stream::Pending(c) => Some(c.clone()), + Stream::Buf { .. } => None, + }; + match chain { + Some(mut c) => { + c.push(spec); + Stream::Pending(c) + } + None => { + let mut b = f.borrow_mut(); + let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; + let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; + *pos = pos.saturating_add(used); + Stream::Buf { data: out, pos: 0, rgb } + } + } + } + Obj::Str(s) => { + self.alloc(s.borrow().len())?; + let (data, _, rgb) = decode(&spec, &s.borrow())?; + Stream::Buf { data, pos: 0, rgb } + } + p @ Obj::Array { .. } => { + let raw = self.gather(&p, MAX_DATA)?; + let (data, _, rgb) = decode(&spec, &raw)?; + Stream::Buf { data, pos: 0, rgb } + } + _ => return ps_err("typecheck", "filter"), + }; + self.push(Obj::File(Rc::new(RefCell::new(stream)))) + } + + /// Decode the filters a file waits to run over the program's data, from where it is now. + fn materialize(&mut self, f: &Rc>) -> Res { + let chain = match &*f.borrow() { + Stream::Pending(c) => c.clone(), + _ => return Ok(()), + }; + let mut buf: Option<(Vec, Option)> = None; + for spec in &chain { + buf = Some(match buf.take() { + None => { + let raw = self.lex.data(); + let (data, used, rgb) = decode(spec, raw)?; + self.alloc(data.len())?; + self.lex.advance(used); + (data, rgb) + } + Some((prev, was)) => { + let (data, _, rgb) = decode(spec, &prev)?; + self.alloc(data.len())?; + (data, rgb.or(was)) + } + }); + } + let (data, rgb) = buf.unwrap_or_default(); + *f.borrow_mut() = Stream::Buf { data, pos: 0, rgb }; + Ok(()) + } + + /// The bytes a data procedure gives, called until it gives an empty string or `n` bytes. + fn gather(&mut self, p: &Obj, n: usize) -> Res> { + let mut out = vec![]; + while out.len() < n { + self.call(p.clone())?; + let s = self.pop_str()?; + let s = s.borrow(); + if s.is_empty() { + break; + } + self.alloc(s.len())?; + out.extend_from_slice(&s); + } + out.truncate(n); + Ok(out) + } + + /// `n` bytes of image data from `src` (zeros past its end). + fn source_bytes(&mut self, src: &Source, n: usize) -> Res> { + let mut v = match src { + Source::File(f) => self.read(f, n)?, + Source::Str(s) if !s.is_empty() => s.iter().copied().cycle().take(n).collect(), + Source::Str(_) => vec![], + Source::Proc(p) => self.gather(p, n)?, + }; + v.resize(n, 0); + Ok(v) + } + + /// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's + /// fonts, by name): the font dictionary it would complete is defined as it is. + fn eexec(&mut self) -> Res { + self.pop()?; + if let Some(Obj::Dict(d)) = self.stack.last().cloned() { + let name = d.borrow().get(&Key::name("FontName")).cloned(); + if let Some(k) = name.and_then(|n| n.key()) { + self.pop()?; + self.fonts.borrow_mut().insert(k, Obj::Dict(d)); + } + } + let rest = self.lex.data(); + let at = find(rest, b"cleartomark").ok_or(PsError::Ps("ioerror", "eexec".into()))?; + self.lex.advance(at); + self.push(Obj::Mark) + } + + // ---------- images ---------- + + fn image(&mut self, op: Op) -> Res { + let mask = op == Op::ImageMask; + let spec = match self.stack.last() { + Some(Obj::Dict(_)) if op != Op::ColorImage => { + let d = self.pop_dict()?; + self.image_dict(&d, mask)? + } + _ => self.image_operands(op)?, + }; + let Some(img) = self.decode_image(spec)? else { return Ok(()) }; + self.place_image(img) + } + + /// The dictionary form of `image` and `imagemask`. + fn image_dict(&mut self, d: &DictRef, mask: bool) -> Res { + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let num = |k: &str| get(k).and_then(|o| o.as_num()); + let kind = num("ImageType").unwrap_or(1.0); + if kind != 1.0 && kind != 4.0 { + return ps_err("rangecheck", "ImageType"); + } + let size = |k: &str| num(k).filter(|v| *v >= 1.0 && *v <= 65_535.0).map(|v| v as usize).ok_or(PsError::Ps("rangecheck", k.to_string())); + let (w, h) = (size("Width")?, size("Height")?); + let bpc = if mask { 1 } else { num("BitsPerComponent").unwrap_or(8.0) as u8 }; + let matrix = + get("ImageMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).ok_or(PsError::Ps("typecheck", "ImageMatrix".into()))?; + let decode: Vec = get("Decode").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default(); + let space = if mask { Space::Gray } else { (*self.g.space).clone() }; + let multi = get("MultipleDataSources").is_some_and(|o| matches!(o, Obj::Bool(true))); + let src = get("DataSource").ok_or(PsError::Ps("undefined", "DataSource".into()))?; + let sources = match (&src, multi) { + (Obj::Array { items, exec: false }, true) => items.borrow().iter().map(source).collect::>>()?, + _ => vec![source(&src)?], + }; + let mask_color: Option> = (kind == 4.0) + .then(|| { + get("MaskColor").and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(|v| v.as_num().map(|v| v.max(0.0) as u32)).collect())) + }) + .flatten(); + // An image mask paints its 0 bits with Decode [0 1], its 1 bits with [1 0]. + let stencil = mask.then(|| decode.first().is_some_and(|v| *v > 0.5)); + Ok(Spec { w, h, bpc, space, decode, matrix, sources, mask_color, stencil }) + } + + /// The operand forms: `w h bpc matrix src image`, `w h polarity matrix src imagemask`, and + /// `w h bpc matrix src… multi ncomp colorimage`. + fn image_operands(&mut self, op: Op) -> Res { + let (n, multi) = if op == Op::ColorImage { + let n = self.pop_count()?; + (n, self.pop_bool()?) + } else { + (1, false) + }; + let space = match n { + 1 => Space::Gray, + 3 => Space::Rgb, + 4 => Space::Cmyk, + _ => return ps_err("rangecheck", "colorimage"), + }; + let mut sources = vec![]; + for _ in 0..if multi { n } else { 1 } { + let o = self.pop()?; + sources.push(source(&o)?); + } + sources.reverse(); + let matrix = self.pop_matrix()?; + let (bpc, stencil) = if op == Op::ImageMask { (1, Some(self.pop_bool()?)) } else { (self.pop_int()?.clamp(1, 16) as u8, None) }; + let size = |v: i64| usize::try_from(v).ok().filter(|v| (1..=65_535).contains(v)).ok_or(PsError::Ps("rangecheck", "image".into())); + let h = size(self.pop_int()?)?; + let w = size(self.pop_int()?)?; + Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) + } + + /// The image's pixels, straight RGBA, rows from the first one read. + fn decode_image(&mut self, s: Spec) -> Res> { + if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { + return ps_err("rangecheck", "BitsPerComponent"); + } + if s.w.saturating_mul(s.h) > MAX_PIXELS { + self.out.warn("images too large to read were left out"); + return Ok(None); + } + self.alloc(s.w * s.h * 4)?; + let n = if s.stencil.is_some() { 1 } else { s.space.n() }; + let bpc = usize::from(s.bpc); + for src in &s.sources { + if let Source::File(f) = src { + self.materialize(f)?; + } + } + // A decoded JPEG gives 8-bit grey or RGB samples whatever the colour space says. + let jpeg = s.sources.iter().find_map(|src| match src { + Source::File(f) => match &*f.borrow() { + Stream::Buf { rgb, .. } => *rgb, + _ => None, + }, + _ => None, + }); + let (n, bpc, space) = match jpeg { + Some(3) => (3, 8, Space::Rgb), + Some(_) => (1, 8, Space::Gray), + None => (n, bpc, s.space.clone()), + }; + let multi = s.sources.len() > 1; + let row_bits = if multi { s.w * bpc } else { s.w * n * bpc }; + let row = row_bits.div_ceil(8); + let planes: Vec> = if multi { + let mut v = vec![]; + for src in &s.sources { + v.push(self.source_bytes(src, row * s.h)?); + } + v + } else { + let src = s.sources.first().ok_or(PsError::Ps("undefined", "DataSource".into()))?; + vec![self.source_bytes(src, row * s.h)?] + }; + let max = ((1u32 << bpc.min(16)) - 1) as f64; + let sample = |plane: &[u8], y: usize, i: usize| -> u32 { + let bit = y * row * 8 + i * bpc; + let byte = bit / 8; + match bpc { + 8 => u32::from(plane.get(byte).copied().unwrap_or(0)), + 16 => u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)), + 12 => { + let v = u32::from(plane.get(byte).copied().unwrap_or(0)) << 8 | u32::from(plane.get(byte + 1).copied().unwrap_or(0)); + if bit.is_multiple_of(8) { v >> 4 } else { v & 0xfff } + } + _ => (u32::from(plane.get(byte).copied().unwrap_or(0)) >> (8 - bpc - bit % 8)) & ((1 << bpc) - 1), + } + }; + let default_decode: Vec = match &space { + Space::Indexed { .. } => vec![0.0, max], + _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), + }; + let decode = if s.decode.len() >= 2 * n && jpeg.is_none() { s.decode.clone() } else { default_decode }; + let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); + let mut rgba = Vec::with_capacity(s.w * s.h * 4); + let mut cache: HashMap, [u8; 3]> = HashMap::new(); + let mut raw = vec![0u32; n]; + let mut comps = vec![0f64; n]; + for y in 0..s.h { + for x in 0..s.w { + for (c, slot) in raw.iter_mut().enumerate() { + *slot = match planes.as_slice() { + [one] => sample(one, y, x * n + c), + many => many.get(c).map_or(0, |p| sample(p, y, x)), + }; + } + if let Some(paint_ones) = s.stencil { + let on = (raw.first().copied().unwrap_or(0) == 1) == paint_ones; + let [r, g, b, _] = paint.unwrap_or([0, 0, 0, 255]); + rgba.extend([r, g, b, if on { 255 } else { 0 }]); + continue; + } + let masked = s.mask_color.as_ref().is_some_and(|m| masked(m, &raw)); + for (c, slot) in comps.iter_mut().enumerate() { + let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); + *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; + } + let rgb = match (&space, raw.as_slice()) { + (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], + (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], + _ => match cache.get(&raw) { + Some(c) => *c, + None => { + let c = self.sample_rgb(&space, &comps)?; + if cache.len() < MAX_TINTS { + cache.insert(raw.clone(), c); + } + c + } + }, + }; + rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); + } + } + Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, matrix: s.matrix })) + } + + /// One pixel's colour in `space` as display RGB. + fn sample_rgb(&mut self, space: &Space, comps: &[f64]) -> Res<[u8; 3]> { + let color = match space { + Space::Gray | Space::Rgb | Space::Cmyk => process(space, comps), + _ => match self.paint_of(space, comps)? { + Paint::Solid { color, .. } => Some(color), + _ => None, + }, + }; + let [r, g, b, _] = color.unwrap_or(Color::BLACK).to_rgba8(1.0); + Ok([r, g, b]) + } + + /// Place decoded pixels in user space (its unit square through the image matrix). + fn place_image(&mut self, img: Pixels) -> Res { + let m = img.matrix; + if m.determinant().abs() < 1e-12 { + return Ok(()); + } + let xf = self.xf() * m.inverse(); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return Ok(()); + } + let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; + let mut png = vec![]; + if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + self.out.warn("an image that couldn't be read was left out"); + return Ok(()); + } + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: img.w, height: img.h, xf, link: None, placement: Default::default() }; + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Image(im)), &clips); + Ok(()) + } +} + +/// An image to decode. +struct Spec { + w: usize, + h: usize, + bpc: u8, + space: Space, + decode: Vec, + /// User space → image space. + matrix: Affine, + /// One source, or one per component. + sources: Vec, + /// `MaskColor` (ImageType 4): samples that are transparent. + mask_color: Option>, + /// An image mask: whether its 1 bits paint. + stencil: Option, +} + +/// Decoded pixels. +struct Pixels { + w: u32, + h: u32, + rgba: Vec, + matrix: Affine, +} + +fn source(o: &Obj) -> Res { + Ok(match o { + Obj::File(f) => Source::File(f.clone()), + Obj::Str(s) => Source::Str(s.borrow().clone()), + p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }), + _ => return ps_err("typecheck", "DataSource"), + }) +} + +/// Do samples `raw` match `MaskColor` `m` (one value per component, or a range)? +fn masked(m: &[u32], raw: &[u32]) -> bool { + if m.len() == raw.len() { + return m.iter().zip(raw).all(|(a, b)| a == b); + } + m.len() == raw.len() * 2 + && raw.iter().enumerate().all(|(i, v)| (m.get(2 * i).copied().unwrap_or(0)..=m.get(2 * i + 1).copied().unwrap_or(0)).contains(v)) +} diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs new file mode 100644 index 00000000..823e2ed9 --- /dev/null +++ b/crates/eps/src/import/graphics.rs @@ -0,0 +1,1197 @@ +//! The graphics state and the operators that draw: coordinates, paths, colours, painting, +//! clipping and shadings. Painted paths become document objects as they are drawn ([`Out`]). + +use std::rc::Rc; +use std::sync::Arc; + +use kurbo::{PathEl, Shape}; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::clipnest::{Clip, Drawn}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; + +use super::interp::{Interp, MAX_GSAVE, matrix_obj}; +use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err}; + +/// Most objects one file makes. +const MAX_NODES: usize = 1 << 20; +/// Objects reaching further than this from the page (points) come from damaged coordinates and +/// are left out. +const LIMIT: f64 = 1e6; +/// Width of a zero-width line (the thinnest a device draws), points. +const HAIRLINE: f64 = 0.25; +/// Most entries an indexed colour space's table has. +const MAX_HIVAL: usize = 4095; +/// Most elements the current path may have. +const MAX_PATH: usize = 1 << 20; +/// Most path elements drawn in all. +const MAX_DRAWN: usize = 1 << 24; +/// Samples taken of a shading function that isn't a plain interpolation. +const SHADING_SAMPLES: usize = 32; + +const FAR_AWAY: &str = "objects far outside the page were left out"; +const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out"; +const TILING_PATTERNS: &str = "pattern fills are filled with mid-grey"; +const SAMPLED_FUNCTIONS: &str = "shadings whose colours come from sampled functions are filled with their middle colour"; + +/// A colour space. +#[derive(Clone, Debug)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + /// Separation and DeviceN: the ink names, the alternate space and the tint transform. + Inks { + names: Vec>, + alt: Rc, + tint: Obj, + }, + Indexed { + base: Rc, + table: Rc>, + }, + Pattern, +} + +impl Space { + /// Components a colour has in it. + pub fn n(&self) -> usize { + match self { + Self::Gray | Self::Indexed { .. } | Self::Pattern => 1, + Self::Rgb => 3, + Self::Cmyk => 4, + Self::Inks { names, .. } => names.len().max(1), + } + } + + /// The colour `setcolorspace` starts with. + fn initial(&self) -> Vec { + match self { + Self::Cmyk => vec![0.0, 0.0, 0.0, 1.0], + Self::Inks { names, .. } => vec![1.0; names.len().max(1)], + s => vec![0.0; s.n()], + } + } +} + +/// A process colour from components in `space` (grey, RGB or CMYK), each clamped to 0–1. +pub(crate) fn process(space: &Space, c: &[f64]) -> Option { + let v = |i: usize| c.get(i).map_or(0.0, |x| if x.is_finite() { x.clamp(0.0, 1.0) as f32 } else { 0.0 }); + Some(match space { + Space::Gray => Color::gray(1.0 - v(0)), + Space::Rgb => Color::rgb(v(0), v(1), v(2)), + Space::Cmyk => Color::cmyk(v(0), v(1), v(2), v(3)), + _ => return None, + }) +} + +#[derive(Clone, Debug)] +pub(crate) struct GState { + /// User space → PostScript's default space. + pub ctm: Affine, + pub space: Rc, + /// The `setcolorspace` operand, for `currentcolorspace`. + pub space_obj: Obj, + pub comps: Vec, + pub paint: Paint, + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + pub dash: Vec, + pub dash_offset: f64, + /// The current path, in document space. + pub path: BezPath, + pub cur: Option, + pub start: Option, + pub clips: Vec>, + pub overprint: bool, + pub font: Option, +} + +impl Default for GState { + fn default() -> Self { + Self { + ctm: Affine::IDENTITY, + space: Rc::new(Space::Gray), + space_obj: Obj::name("DeviceGray"), + comps: vec![0.0], + paint: Paint::solid(Color::gray(1.0)), + width: 1.0, + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 10.0, + dash: vec![], + dash_offset: 0.0, + path: BezPath::new(), + cur: None, + start: None, + clips: vec![], + overprint: false, + font: None, + } + } +} + +/// What the program draws. +pub(crate) struct Out { + /// The document being made: ids, images and swatches. + pub doc: Document, + pub drawn: Drawn, + pub warnings: Vec, + /// PostScript's default space → document space. + pub page: Affine, + /// The page (the bounding box) in document space. + pub frame: Rect, + next_clip: u32, + /// Path elements drawn so far (see [`MAX_DRAWN`]). + elements: usize, + /// The path the last object was filled with: a stroke of the same path right after joins it. + merge: Option, + /// Process colours painted, by model (the document opens in CMYK when most are). + pub cmyk: usize, + pub rgb: usize, + /// Spot inks used: name and colour at full tint. + pub spots: Vec<(String, Color)>, + /// Rectangles painted with a shading (`shfill`): a clipping group of one is the clip path + /// filled with it. + pub shadings: Vec, +} + +fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) +} + +impl Out { + pub fn new(doc: Document, page: Affine, frame: Rect) -> Self { + Self { + doc, + drawn: vec![], + warnings: vec![], + page, + frame, + next_clip: 0, + elements: 0, + merge: None, + cmyk: 0, + rgb: 0, + spots: vec![], + shadings: vec![], + } + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + /// Add `node` under `clips`; `None` when it was left out. + pub fn push(&mut self, mut node: Node, clips: &[Arc]) -> Option { + self.merge = None; + self.elements = self.elements.saturating_add(node.path_data().map_or(1, |p| p.subpaths.iter().map(|s| s.anchors.len()).sum())); + if self.drawn.len() >= MAX_NODES || self.elements > MAX_DRAWN { + self.warn(TOO_MUCH); + return None; + } + if !node.visual_bounds().is_none_or(sane) { + self.warn(FAR_AWAY); + return None; + } + node.id = self.doc.alloc_id(); + let id = node.id; + self.drawn.push((clips.to_vec(), node)); + Some(id) + } + + /// Count a paint's colour model. + fn count(&mut self, p: &Paint) { + match p { + Paint::Solid { color: Color::Cmyk { .. }, swatch: None, .. } => self.cmyk += 1, + Paint::Solid { color: Color::Rgb { .. }, swatch: None, .. } => self.rgb += 1, + _ => {} + } + } + + /// Fill `bp` with `paint`. + fn fill(&mut self, bp: BezPath, rule: FillRule, paint: Paint, overprint: bool, clips: &[Arc]) { + self.count(&paint); + let item = AppearanceItem::Fill(FillLayer { overprint, ..FillLayer::new(paint) }); + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + if self.push(n, clips).is_some() { + self.merge = Some(bp); + } + } + + /// Stroke `bp` with `st`: joins the object just filled with the same path, under the same + /// clips (`gsave fill grestore stroke`). + fn stroke(&mut self, bp: BezPath, st: StrokeLayer, clips: &[Arc]) { + self.count(&st.paint); + if self.merge.as_ref() == Some(&bp) + && let Some((chain, last)) = self.drawn.last_mut() + && chain.len() == clips.len() + && chain.iter().zip(clips).all(|(a, b)| Arc::ptr_eq(a, b)) + { + last.appearance.items.push(AppearanceItem::Stroke(st)); + self.merge = None; + return; + } + let n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() }); + self.push(n, clips); + } + + /// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which + /// clips nothing. + fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { + let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..))); + let around = path.bounding_box().inflate(0.5, 0.5); + if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) { + return None; + } + self.next_clip += 1; + Some(Arc::new(Clip { id: self.next_clip, path, rule })) + } +} + +/// The smallest angle from `a` to `b` (radians, −π to π). +fn turn(a: f64, b: f64) -> f64 { + let d = (b - a).rem_euclid(std::f64::consts::TAU); + if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d } +} + +impl Interp<'_> { + /// User space → document space. + pub fn xf(&self) -> Affine { + self.out.page * self.g.ctm + } + + /// How much user-space lengths grow in the document. + pub fn scale(&self) -> f64 { + let s = self.xf().determinant().abs().sqrt(); + if s.is_finite() { s } else { 0.0 } + } + + /// The current point in user space. + pub fn user_point(&self) -> Res { + let p = self.g.cur.ok_or(PsError::Ps("nocurrentpoint", String::new()))?; + let inv = self.xf(); + if inv.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + Ok(inv.inverse() * p) + } + + pub fn gsave(&mut self) -> Res { + if self.saved.len() >= MAX_GSAVE { + return Err(PsError::Limit("too many saved graphics states")); + } + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; + self.saved.push(self.g.clone()); + Ok(()) + } + + pub fn grestore(&mut self) { + if let Some(g) = self.saved.pop() { + self.g = g; + } + } + + // ---------- paths ---------- + + /// Room for one more element in the current path. + pub fn grow(&self) -> Res { + if self.g.path.elements().len() >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + Ok(()) + } + + fn move_to(&mut self, p: Point) -> Res { + self.grow()?; + // Consecutive movetos: the last one counts. + if let Some(PathEl::MoveTo(_)) = self.g.path.elements().last() { + self.g.path.pop(); + } + self.g.path.move_to(p); + self.g.cur = Some(p); + self.g.start = Some(p); + Ok(()) + } + + fn line_to(&mut self, p: Point) -> Res { + self.grow()?; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + self.g.path.line_to(p); + self.g.cur = Some(p); + Ok(()) + } + + /// Path elements in user space appended (the first move a line when there is a current point). + fn append_user(&mut self, els: impl Iterator) -> Res { + let xf = self.xf(); + for el in els { + match el { + PathEl::MoveTo(p) => { + let p = xf * p; + if self.g.cur.is_some() { self.line_to(p)? } else { self.move_to(p)? } + } + PathEl::LineTo(p) => self.line_to(xf * p)?, + PathEl::QuadTo(a, p) => { + self.grow()?; + self.g.path.quad_to(xf * a, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::CurveTo(a, b, p) => { + self.grow()?; + self.g.path.curve_to(xf * a, xf * b, xf * p); + self.g.cur = Some(xf * p); + } + PathEl::ClosePath => self.g.path.close_path(), + } + } + Ok(()) + } + + /// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise). + fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res { + let (a1, mut a2) = (a1.to_radians(), a2.to_radians()); + let tau = std::f64::consts::TAU; + if ccw { + while a2 < a1 { + a2 += tau; + } + } else { + while a2 > a1 { + a2 -= tau; + } + } + let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0); + let start = c + Vec2::new(r * a1.cos(), r * a1.sin()); + self.append_user(std::iter::once(PathEl::MoveTo(start)))?; + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0); + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + self.append_user(arc.append_iter(tol)) + } + + /// `arct` / `arcto`: a line towards `p1` rounded with radius `r` into the line to `p2`; the + /// tangent points (user space). + fn arct(&mut self, p1: Point, p2: Point, r: f64) -> Res<[Point; 2]> { + let p0 = self.user_point()?; + let (u, v) = (p0 - p1, p2 - p1); + let (lu, lv) = (u.hypot(), v.hypot()); + let cross = u.cross(v); + if lu < 1e-12 || lv < 1e-12 || cross.abs() < 1e-12 || r <= 0.0 { + self.append_user(std::iter::once(PathEl::LineTo(p1)))?; + return Ok([p1, p1]); + } + let (u, v) = (u / lu, v / lv); + let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0; + let dist = r / half.tan(); + let (t1, t2) = (p1 + u * dist, p1 + v * dist); + let bis = (u + v).normalize(); + let c = p1 + bis * (r / half.sin()); + let (a1, a2) = ((t1 - c).atan2(), (t2 - c).atan2()); + self.append_user(std::iter::once(PathEl::LineTo(t1)))?; + let tol = (0.01 / self.scale().max(1e-9)).max(1e-9); + let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, turn(a1, a2), 0.0); + self.append_user(arc.append_iter(tol))?; + Ok([t1, t2]) + } + + fn rect_path(&self, x: f64, y: f64, w: f64, h: f64) -> BezPath { + let xf = self.xf(); + let mut bp = BezPath::new(); + bp.move_to(xf * Point::new(x, y)); + bp.line_to(xf * Point::new(x + w, y)); + bp.line_to(xf * Point::new(x + w, y + h)); + bp.line_to(xf * Point::new(x, y + h)); + bp.close_path(); + bp + } + + /// The rectangles of `rectfill`, `rectstroke` and `rectclip`: `x y w h` or an array of them. + fn rects(&mut self) -> Res { + let nums: Vec = match self.stack.last() { + Some(Obj::Array { .. }) => { + let items = self.pop_array()?; + let v: Option> = items.borrow().iter().map(Obj::as_num).collect(); + v.ok_or(PsError::Ps("typecheck", String::new()))? + } + _ => self.nums::<4>()?.to_vec(), + }; + if nums.len() / 4 * 5 >= MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + let mut bp = BezPath::new(); + for r in nums.chunks_exact(4) { + if let [x, y, w, h] = r { + bp.extend(self.rect_path(*x, *y, *w, *h)); + } + } + Ok(bp) + } + + // ---------- painting ---------- + + fn fill_path(&mut self, bp: BezPath, rule: FillRule) { + if bp.elements().is_empty() || self.g.paint.is_none() { + return; + } + let (paint, overprint) = (self.g.paint.clone(), self.g.overprint); + self.out.fill(bp, rule, paint, overprint, &self.g.clips); + } + + fn stroke_path(&mut self, bp: BezPath) { + if bp.elements().is_empty() || self.g.paint.is_none() { + return; + } + let k = self.scale(); + let w = self.g.width * k; + let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); + st.cap = self.g.cap; + st.join = self.g.join; + st.miter_limit = self.g.miter.clamp(1.0, 500.0); + st.overprint = self.g.overprint; + let pattern: Vec = self.g.dash.iter().map(|d| d * k).collect(); + let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + self.out.stroke(bp, st, &self.g.clips); + } + + fn take_path(&mut self) -> BezPath { + self.g.cur = None; + self.g.start = None; + std::mem::take(&mut self.g.path) + } + + fn clip_with(&mut self, path: BezPath, rule: FillRule) { + if let Some(c) = self.out.clip(path, rule) { + self.g.clips.push(c); + } + } + + /// The area the clip leaves, in document space (the page without a clip). + fn clip_bounds(&self) -> Rect { + self.g.clips.last().map_or(self.out.frame, |c| c.path.bounding_box()) + } + + // ---------- colour ---------- + + /// A colour space operand. + fn space_of(&mut self, o: &Obj, depth: u32) -> Res { + if depth > 4 { + return ps_err("limitcheck", "setcolorspace"); + } + let (family, items) = match o { + Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]), + Obj::Array { items, .. } => { + let v = items.borrow().clone(); + let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?; + (n, v) + } + _ => return ps_err("typecheck", "setcolorspace"), + }; + let arg = |i: usize| items.get(i).cloned().ok_or(PsError::Ps("rangecheck", "setcolorspace".into())); + Ok(match &*family { + "DeviceGray" | "CIEBasedA" | "CalGray" => Space::Gray, + "DeviceRGB" | "CIEBasedABC" | "CalRGB" | "Lab" | "CIEBasedDEF" => Space::Rgb, + "DeviceCMYK" | "CIEBasedDEFG" => Space::Cmyk, + "Pattern" => Space::Pattern, + "ICCBased" => match arg(1)? { + Obj::Dict(d) => match d.borrow().get(&Key::name("N")).and_then(Obj::as_num) { + Some(1.0) => Space::Gray, + Some(4.0) => Space::Cmyk, + _ => Space::Rgb, + }, + _ => Space::Rgb, + }, + "Separation" | "DeviceN" => { + let names: Vec> = match arg(1)? { + Obj::Array { items, .. } => items.borrow().iter().filter_map(Obj::text).collect(), + o => o.text().into_iter().collect(), + }; + let alt = self.space_of(&arg(2)?, depth + 1)?; + Space::Inks { names, alt: Rc::new(alt), tint: arg(3)? } + } + "Indexed" => { + let base = self.space_of(&arg(1)?, depth + 1)?; + let hival = + arg(2)?.as_num().filter(|v| (0.0..=MAX_HIVAL as f64).contains(v)).ok_or(PsError::Ps("rangecheck", "Indexed".into()))? as usize; + let n = base.n(); + let table: Vec = match arg(3)? { + Obj::Str(s) => s.borrow().iter().map(|b| f64::from(*b) / 255.0).collect(), + p @ Obj::Array { .. } => { + let mut t = Vec::with_capacity((hival + 1) * n); + for i in 0..=hival { + self.push(Obj::Int(i as i64))?; + self.call(p.clone())?; + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + t.extend(c); + } + t + } + _ => return ps_err("typecheck", "Indexed"), + }; + Space::Indexed { base: Rc::new(base), table: Rc::new(table) } + } + _ => return ps_err("undefined", &family), + }) + } + + fn set_space(&mut self, o: Obj) -> Res { + let space = self.space_of(&o, 0)?; + let comps = space.initial(); + self.g.space = Rc::new(space); + self.g.space_obj = o; + self.set_comps(comps) + } + + /// Set the colour to `comps` in the current space. + fn set_comps(&mut self, comps: Vec) -> Res { + let space = self.g.space.clone(); + self.g.paint = self.paint_of(&space, &comps)?; + self.g.comps = comps; + Ok(()) + } + + /// Set a process colour, its space with it. + fn set_process(&mut self, space: Space, name: &str, comps: Vec) -> Res { + self.g.space = Rc::new(space); + self.g.space_obj = Obj::name(name); + self.set_comps(comps) + } + + /// The paint of colour `comps` in `space`: a spot ink links to its swatch at the tint. + pub fn paint_of(&mut self, space: &Space, comps: &[f64]) -> Res { + Ok(match space { + Space::Inks { names, alt, tint } => { + let t = comps.first().copied().unwrap_or(1.0).clamp(0.0, 1.0) as f32; + match names.as_slice() { + [one] if &**one == "None" => Paint::None, + [one] if &**one == "All" => { + Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), swatch: Some(REGISTRATION.into()), tint: t } + } + [one] => { + let full = match self.out.spots.iter().find(|(n, _)| **n == **one) { + Some((_, c)) => *c, + None => { + let c = self.tint_color(alt, tint, &[1.0])?; + self.out.spots.push((one.to_string(), c)); + c + } + }; + Paint::Solid { color: full.tinted(t), swatch: Some(one.to_string()), tint: t } + } + _ => Paint::solid(self.tint_color(alt, tint, comps)?), + } + } + Space::Indexed { base, table } => { + let n = base.n(); + let i = comps.first().copied().unwrap_or(0.0).round().max(0.0) as usize; + let c = table.get(i * n..i * n + n).ok_or(PsError::Ps("rangecheck", "setcolor".into()))?.to_vec(); + Paint::solid(process(base, &c).unwrap_or(Color::BLACK)) + } + Space::Pattern => Paint::None, + s => Paint::solid(process(s, comps).unwrap_or(Color::BLACK)), + }) + } + + /// The alternate colour of inks at `comps`, through the tint transform. + pub fn tint_color(&mut self, alt: &Space, tint: &Obj, comps: &[f64]) -> Res { + let base = self.stack.len(); + for c in comps { + self.push_num(*c)?; + } + self.call(tint.clone())?; + let n = alt.n(); + let at = self.stack.len().checked_sub(n).filter(|a| *a >= base).ok_or(PsError::Ps("stackunderflow", "tint transform".into()))?; + let v: Vec = self.stack.split_off(at).iter().map(|o| o.as_num().unwrap_or(0.0)).collect(); + self.stack.truncate(base); + Ok(process(alt, &v).unwrap_or(Color::BLACK)) + } + + /// The current colour as grey, RGB or CMYK components (`currentgray` …). + fn current_rgb(&self) -> [f32; 3] { + self.g.paint.color().map_or([0.0; 3], |c| c.to_rgb_uncalibrated()) + } + + /// A pattern colour: a shading pattern's gradient (others mid-grey). + fn pattern_paint(&mut self, d: &DictRef) -> Res { + let kind = d.borrow().get(&Key::name("PatternType")).and_then(Obj::as_num); + let sh = d.borrow().get(&Key::name("Shading")).cloned(); + match (kind, sh) { + (Some(2.0), Some(Obj::Dict(sh))) => { + let m = d.borrow().get(&Key::name("VCMatrix")).and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow()))); + let m = self.out.page * m.unwrap_or(self.g.ctm); + Ok(self.gradient(&sh, m)?.map_or(Paint::None, |g| Paint::Gradient(Box::new(g)))) + } + _ => { + self.out.warn(TILING_PATTERNS); + Ok(Paint::solid(Color::gray(0.5))) + } + } + } + + /// An axial or radial shading as a gradient, `m` mapping its space onto the document. + fn gradient(&mut self, sh: &DictRef, m: Affine) -> Res> { + let get = |k: &str| sh.borrow().get(&Key::name(k)).cloned(); + let kind = get("ShadingType").and_then(|o| o.as_num()).unwrap_or(0.0); + if kind != 2.0 && kind != 3.0 { + self.out.warn("shadings other than axial and radial ones (meshes, functions) were left out"); + return Ok(None); + } + let space = self.space_of(&get("ColorSpace").ok_or(PsError::Ps("undefined", "ColorSpace".into()))?, 0)?; + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let coords = nums(get("Coords")); + let domain = nums(get("Domain")); + let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; + // Where the colour changes: the ends, and the bounds of stitched linear functions. + let ts: Vec = match linear_points(&f) { + Some(mut pts) => { + pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); + pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() + } + None => (0..=SHADING_SAMPLES).map(|i| i as f64 / SHADING_SAMPLES as f64).collect(), + }; + let mut ts = ts; + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let radial = kind == 3.0; + let c = |i: usize| coords.get(i).copied().unwrap_or(0.0); + let (r0, r1) = (c(2).max(0.0), c(5).max(1e-6)); + let mut stops = Vec::with_capacity(ts.len()); + for t in ts { + let comps = self.eval(&f, d0 + (d1 - d0) * t, 0)?; + let paint = self.paint_of(&space, &comps)?; + let offset = if radial { (r0 + (r1 - r0) * t) / r1 } else { t }; + let stop = match paint { + Paint::Solid { color, swatch, tint } => GradientStop { swatch, tint, ..GradientStop::new(offset as f32, color) }, + _ => GradientStop::new(offset as f32, Color::BLACK), + }; + stops.push(stop); + } + let (gk, geom) = if radial { + let centre = Point::new(c(3), c(4)); + let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1, 0.0), aspect: 1.0, focal: None }; + geom.set_focal(Some(Point::new(c(0), c(1)))); + geom.transform(m, GradientKind::Radial); + (GradientKind::Radial, geom) + } else { + (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) + }; + let mut g = GradientPaint::new(Gradient { kind: gk, stops }); + g.geom = Some(geom); + Ok(Some(g)) + } + + /// A function (types 2 and 3; an array of them, one per component) at `t`. + fn eval(&mut self, f: &Obj, t: f64, depth: u32) -> Res> { + if depth > 8 { + return ps_err("limitcheck", "Function"); + } + let d = match f { + Obj::Dict(d) => d.clone(), + Obj::Array { items, .. } => { + let fs = items.borrow().clone(); + let mut out = vec![]; + for g in &fs { + out.extend(self.eval(g, t, depth + 1)?.first().copied()); + } + return Ok(out); + } + _ => return ps_err("typecheck", "Function"), + }; + let get = |k: &str| d.borrow().get(&Key::name(k)).cloned(); + let nums = |o: Option| -> Vec { + o.and_then(|o| o.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect())).unwrap_or_default() + }; + let domain = nums(get("Domain")); + let t = match domain.as_slice() { + [a, b, ..] => t.clamp(a.min(*b), a.max(*b)), + _ => t, + }; + match get("FunctionType").and_then(|o| o.as_num()) { + Some(2.0) => { + let c0 = nums(get("C0")); + let c1 = nums(get("C1")); + let (c0, c1) = (if c0.is_empty() { vec![0.0] } else { c0 }, if c1.is_empty() { vec![1.0] } else { c1 }); + let n = get("N").and_then(|o| o.as_num()).unwrap_or(1.0); + let x = t.max(0.0).powf(n); + Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) + } + Some(3.0) => { + let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default(); + let bounds = nums(get("Bounds")); + let encode = nums(get("Encode")); + let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); + let k = bounds.iter().take_while(|b| t >= **b).count().min(fs.len().saturating_sub(1)); + let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; + let b = bounds.get(k).copied().unwrap_or(hi); + let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); + let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; + let g = fs.get(k).cloned().ok_or(PsError::Ps("rangecheck", "Functions".into()))?; + self.eval(&g, u, depth + 1) + } + _ => { + self.out.warn(SAMPLED_FUNCTIONS); + let r = nums(get("Range")); + Ok(r.chunks(2).map(|p| p.iter().sum::() / 2.0).collect()) + } + } + } + + // ---------- the operators ---------- + + pub fn graphics_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + Gsave => self.gsave()?, + Grestore => self.grestore(), + GrestoreAll => { + if let Some(g) = self.saved.first().cloned() { + self.saved.clear(); + self.g = g; + } + } + InitGraphics => { + let font = self.g.font.take(); + self.g = GState { font, ..GState::default() }; + } + GStateNew => self.push(Obj::GState(Rc::new(self.g.clone())))?, + CurrentGState => { + self.pop()?; + self.push(Obj::GState(Rc::new(self.g.clone())))?; + } + SetGState => match self.pop()? { + Obj::GState(g) => self.g = (*g).clone(), + _ => return ps_err("typecheck", ""), + }, + SetLineWidth => self.g.width = self.pop_num()?.abs(), + CurrentLineWidth => self.push_num(self.g.width)?, + SetLineCap => { + self.g.cap = match self.pop_int()? { + 1 => LineCap::Round, + 2 => LineCap::Square, + _ => LineCap::Butt, + } + } + CurrentLineCap => self.push(Obj::Int(self.g.cap as i64))?, + SetLineJoin => { + self.g.join = match self.pop_int()? { + 1 => LineJoin::Round, + 2 => LineJoin::Bevel, + _ => LineJoin::Miter, + } + } + CurrentLineJoin => self.push(Obj::Int(self.g.join as i64))?, + SetMiterLimit => self.g.miter = self.pop_num()?, + CurrentMiterLimit => self.push_num(self.g.miter)?, + SetDash => { + let offset = self.pop_num()?; + let items = self.pop_array()?; + let v: Option> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect(); + self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?; + self.g.dash_offset = offset; + } + CurrentDash => { + let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect(); + self.push(Obj::array(d))?; + self.push_num(self.g.dash_offset)?; + } + SetGray => { + let [g] = self.nums()?; + self.set_process(Space::Gray, "DeviceGray", vec![g])?; + } + SetRgbColor => { + let c = self.nums::<3>()?; + self.set_process(Space::Rgb, "DeviceRGB", c.to_vec())?; + } + SetHsbColor => { + let [h, s, b] = self.nums()?; + let [r, g, bl] = + Color::from_hsb((h.rem_euclid(1.0) * 360.0) as f32, s.clamp(0.0, 1.0) as f32, b.clamp(0.0, 1.0) as f32).to_rgb_uncalibrated(); + self.set_process(Space::Rgb, "DeviceRGB", vec![f64::from(r), f64::from(g), f64::from(bl)])?; + } + SetCmykColor => { + let c = self.nums::<4>()?; + self.set_process(Space::Cmyk, "DeviceCMYK", c.to_vec())?; + } + CurrentGray => { + let [r, g, b] = self.current_rgb(); + self.push_num(f64::from(0.3 * r + 0.59 * g + 0.11 * b))?; + } + CurrentRgbColor => { + for v in self.current_rgb() { + self.push_num(f64::from(v))?; + } + } + CurrentCmykColor => { + let c = self.g.paint.color().map_or([0.0, 0.0, 0.0, 1.0], |c| c.to_cmyk()); + for v in c { + self.push_num(f64::from(v))?; + } + } + SetColorSpace => { + let o = self.pop()?; + self.set_space(o)?; + } + CurrentColorSpace => { + let o = match &self.g.space_obj { + o @ Obj::Array { .. } => o.clone(), + o => Obj::array(vec![o.clone()]), + }; + self.push(o)?; + } + SetColor => { + if matches!(*self.g.space, Space::Pattern) { + let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; + self.g.paint = self.pattern_paint(&d)?; + } else { + let n = self.g.space.n(); + let mut c = vec![0.0; n]; + for slot in c.iter_mut().rev() { + *slot = self.pop_num()?; + } + self.set_comps(c)?; + } + } + CurrentColor => { + for v in self.g.comps.clone() { + self.push_num(v)?; + } + } + SetPattern => { + let Obj::Dict(d) = self.pop()? else { return ps_err("typecheck", "") }; + self.g.space = Rc::new(Space::Pattern); + self.g.space_obj = Obj::name("Pattern"); + self.g.paint = self.pattern_paint(&d)?; + } + MakePattern => { + let m = self.pop_matrix()?; + let d = self.pop_dict()?; + let mut copy = d.borrow().clone(); + copy.insert(Key::name("VCMatrix"), matrix_obj(self.g.ctm * m)); + self.push(Obj::dict(copy))?; + } + SetOverprint => self.g.overprint = self.pop_bool()?, + CurrentOverprint => self.push(Obj::Bool(self.g.overprint))?, + SetFlat | SetHalftone | SetTransfer | SetBlackGeneration | SetUnderColorRemoval | SetColorRendering | SetSmoothness | SetStrokeAdjust + | SetPageDevice | SetUserParams | SetSystemParams => { + self.pop()?; + } + SetScreen => { + for _ in 0..3 { + self.pop()?; + } + } + SetColorTransfer => { + for _ in 0..4 { + self.pop()?; + } + } + SetColorScreen => { + for _ in 0..12 { + self.pop()?; + } + } + SetCacheLimit => { + self.pop()?; + } + SetUCacheParams => { + let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; + self.stack.truncate(at); + } + UCache => {} + CurrentScreen => { + self.push(Obj::Real(60.0))?; + self.push(Obj::Real(45.0))?; + self.push(Obj::proc(vec![]))?; + } + CurrentTransfer | CurrentBlackGeneration | CurrentUnderColorRemoval => self.push(Obj::proc(vec![]))?, + CurrentColorTransfer => { + for _ in 0..4 { + self.push(Obj::proc(vec![]))?; + } + } + CurrentHalftone => { + let mut d = super::obj::Dict::new(); + d.insert(Key::name("HalftoneType"), Obj::Int(1)); + d.insert(Key::name("Frequency"), Obj::Real(60.0)); + d.insert(Key::name("Angle"), Obj::Real(45.0)); + d.insert(Key::name("SpotFunction"), Obj::proc(vec![])); + self.push(Obj::dict(d))?; + } + FindEncoding => { + self.pop()?; + self.push(Obj::array(vec![Obj::name(".notdef"); 256]))?; + } + CurrentFlat => self.push(Obj::Real(1.0))?, + CurrentStrokeAdjust => self.push(Obj::Bool(false))?, + CurrentPageDevice => { + let mut d = super::obj::Dict::new(); + let f = self.out.frame; + d.insert(Key::name("PageSize"), Obj::array(vec![Obj::Real(f.width()), Obj::Real(f.height())])); + self.push(Obj::dict(d))?; + } + ShowPage => return Err(PsError::Quit), + CopyPage | ErasePage => {} + Matrix => self.push(matrix_obj(Affine::IDENTITY))?, + IdentMatrix | DefaultMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, Affine::IDENTITY)?; + } + CurrentMatrix => { + let items = self.pop_array()?; + self.put_matrix(items, self.g.ctm)?; + } + SetMatrix => self.g.ctm = self.pop_matrix()?, + InitMatrix => self.g.ctm = Affine::IDENTITY, + Concat => { + let m = self.pop_matrix()?; + self.g.ctm *= m; + } + ConcatMatrix => { + let out = self.pop_array()?; + let (m2, m1) = (self.pop_matrix()?, self.pop_matrix()?); + self.put_matrix(out, m2 * m1)?; + } + Translate | Scale | Rotate => { + let into = match self.stack.last() { + Some(Obj::Array { .. }) => Some(self.pop_array()?), + _ => None, + }; + let m = match op { + Rotate => Affine::rotate(self.pop_num()?.to_radians()), + Translate => { + let [x, y] = self.nums()?; + Affine::translate((x, y)) + } + _ => { + let [x, y] = self.nums()?; + Affine::scale_non_uniform(x, y) + } + }; + match into { + Some(items) => self.put_matrix(items, m)?, + None => self.g.ctm *= m, + } + } + Transform | ITransform | DTransform | IDTransform => { + let m = match self.stack.last() { + Some(Obj::Array { .. }) => self.pop_matrix()?, + _ => self.g.ctm, + }; + let [x, y] = self.nums()?; + let inverse = matches!(op, ITransform | IDTransform); + if inverse && m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let m = if inverse { m.inverse() } else { m }; + let p = if matches!(op, DTransform | IDTransform) { + let v = m * Point::new(x, y) - m * Point::ZERO; + Point::new(v.x, v.y) + } else { + m * Point::new(x, y) + }; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + InvertMatrix => { + let out = self.pop_array()?; + let m = self.pop_matrix()?; + if m.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + self.put_matrix(out, m.inverse())?; + } + NewPath => { + self.take_path(); + } + CurrentPoint => { + let p = self.user_point()?; + self.push_num(p.x)?; + self.push_num(p.y)?; + } + MoveTo => { + let [x, y] = self.nums()?; + let p = self.xf() * Point::new(x, y); + self.move_to(p)?; + } + RMoveTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + let p = self.xf() * p; + self.move_to(p)?; + } + LineTo => { + let [x, y] = self.nums()?; + self.line_to(self.xf() * Point::new(x, y))?; + } + RLineTo => { + let [dx, dy] = self.nums()?; + let p = self.user_point()? + Vec2::new(dx, dy); + self.line_to(self.xf() * p)?; + } + CurveTo | RCurveTo => { + let v = self.nums::<6>()?; + let o = if op == RCurveTo { self.user_point()?.to_vec2() } else { Vec2::ZERO }; + if self.g.cur.is_none() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + let [a, b, p] = [0, 2, 4].map(|i| xf * (Point::new(v[i], v[i + 1]) + o)); + self.grow()?; + self.g.path.curve_to(a, b, p); + self.g.cur = Some(p); + } + Arc | Arcn => { + let [x, y, r, a1, a2] = self.nums()?; + self.arc(Point::new(x, y), r, a1, a2, op == Arc)?; + } + Arct | Arcto => { + let [x1, y1, x2, y2, r] = self.nums()?; + let [t1, t2] = self.arct(Point::new(x1, y1), Point::new(x2, y2), r)?; + if op == Arcto { + for v in [t1.x, t1.y, t2.x, t2.y] { + self.push_num(v)?; + } + } + } + ClosePath => { + if self.g.cur.is_some() { + self.g.path.close_path(); + self.g.cur = self.g.start; + } + } + FlattenPath | ReversePath => {} + PathBBox => { + if self.g.path.elements().is_empty() { + return ps_err("nocurrentpoint", ""); + } + let xf = self.xf(); + if xf.determinant().abs() < 1e-12 { + return ps_err("undefinedresult", ""); + } + let b = xf.inverse().transform_rect_bbox(self.g.path.bounding_box()); + for v in [b.x0, b.y0, b.x1, b.y1] { + self.push_num(v)?; + } + } + ClipPath => { + let b = self.clip_bounds(); + self.take_path(); + self.g.path = b.to_path(0.1); + self.g.cur = Some(b.origin()); + self.g.start = self.g.cur; + } + InitClip => self.g.clips.clear(), + Clip | EoClip => { + let bp = self.g.path.clone(); + self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero }); + } + RectClip => { + let bp = self.rects()?; + self.take_path(); + self.clip_with(bp, FillRule::NonZero); + } + Fill | EoFill => { + let bp = self.take_path(); + self.fill_path(bp, if op == EoFill { FillRule::EvenOdd } else { FillRule::NonZero }); + } + Stroke => { + let bp = self.take_path(); + self.stroke_path(bp); + } + RectFill => { + let bp = self.rects()?; + self.fill_path(bp, FillRule::NonZero); + } + RectStroke => { + let m = match self.stack.last() { + Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?), + _ => None, + }; + let mut bp = self.rects()?; + if let Some(m) = m { + let xf = self.xf(); + bp.apply_affine(xf * m * xf.inverse()); + } + self.stroke_path(bp); + } + ShFill => { + let d = self.pop_dict()?; + let m = self.xf(); + if let Some(g) = self.gradient(&d, m)? { + let area = self.clip_bounds(); + let paint = Paint::Gradient(Box::new(g)); + let clips = self.g.clips.clone(); + self.out.fill(area.to_path(0.1), FillRule::NonZero, paint, self.g.overprint, &clips); + if let Some((_, n)) = self.out.drawn.last() { + let id = n.id; + self.out.shadings.push(id); + } + } + } + _ => self.data_op(op)?, + } + Ok(()) + } +} + +/// Where a stitched function of linear interpolations changes slope (its bounds), when it is +/// one; `None` for functions that must be sampled. +fn linear_points(f: &Obj) -> Option> { + let Obj::Dict(d) = f else { return None }; + let d = d.borrow(); + let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); + match num("FunctionType")? { + 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), + 3.0 => { + let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone(); + if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) { + return None; + } + d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) + } + _ => None, + } +} + +/// The clip path a clipping group of one shading rectangle (`shfill` through a clip) can be +/// filled with instead. +pub(crate) fn collapse(nodes: &mut [Arc], shadings: &[NodeId]) { + for n in nodes.iter_mut() { + let NodeKind::Group { children, clip: true } = &n.kind else { + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + continue; + }; + if let [clip, fill] = children.as_slice() + && shadings.contains(&fill.id) + && let (NodeKind::Path { path, rule, clipping: true, .. }, NodeKind::Path { .. }) = (&clip.kind, &fill.kind) + { + let mut p = Node::path(fill.id, path.clone(), fill.appearance.clone()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = *rule; + } + *n = Arc::new(p); + continue; + } + if let Some(c) = Arc::make_mut(n).children_mut() { + collapse(c, shadings); + } + } +} diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs new file mode 100644 index 00000000..1169aa58 --- /dev/null +++ b/crates/eps/src/import/interp.rs @@ -0,0 +1,1111 @@ +//! The interpreter: the operand and dictionary stacks, running objects and procedures, and the +//! operators that don't draw (stack, arithmetic, control, composites, dictionaries). +//! +//! Every operation counts against a budget and procedures nest only so deep, so a looping or +//! recursing program ends with [`PsError::Limit`] instead of hanging. + +use std::cell::RefCell; +use std::rc::Rc; + +use vectorcraft_geom::Affine; + +use super::graphics::{GState, Out}; +use super::lex::Lexer; +use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; + +/// Most operations one file may run. +const MAX_OPS: u64 = 50_000_000; +/// Deepest procedure nesting. +const MAX_DEPTH: u32 = 400; +/// Most operands on the stack. +const MAX_STACK: usize = 100_000; +/// Largest array, string or dictionary a program may make. +pub(crate) const MAX_ALLOC: usize = 1 << 22; +/// Most dictionaries on the dictionary stack. +const MAX_DICTS: usize = 1000; +/// Most saved graphics states. +pub(crate) const MAX_GSAVE: usize = 1000; +/// Most memory (bytes) a program may ask for in all: strings, arrays, decoded data, images, +/// saved paths. +const MAX_MEMORY: usize = 1 << 30; + +pub(crate) struct Interp<'a> { + pub lex: Lexer<'a>, + pub stack: Vec, + pub dicts: Vec, + system: DictRef, + user: DictRef, + global: DictRef, + status: DictRef, + error: DictRef, + /// `FontDirectory`: fonts defined with `definefont`. + pub fonts: DictRef, + /// Resources by category (`defineresource`). + resources: DictRef, + depth: u32, + ops: u64, + /// Bytes asked for so far (see [`MAX_MEMORY`]). + allocated: usize, + seed: u32, + pub g: GState, + pub saved: Vec, + pub out: Out, +} + +fn new_dict() -> DictRef { + Rc::new(RefCell::new(Dict::new())) +} + +/// An encoding vector: 256 `/.notdef` names (type is read as Latin-1 whatever the encoding). +fn encoding() -> Obj { + Obj::array(vec![Obj::name(".notdef"); 256]) +} + +impl<'a> Interp<'a> { + pub fn new(src: &'a [u8], g: GState, out: Out) -> Self { + let system = new_dict(); + { + let mut d = system.borrow_mut(); + for (op, name) in Op::ALL { + d.insert(Key::name(name), Obj::Op(*op)); + } + for name in ["StandardEncoding", "ISOLatin1Encoding"] { + d.insert(Key::name(name), encoding()); + } + } + let fonts = new_dict(); + system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone())); + system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone())); + let (user, global) = (new_dict(), new_dict()); + Self { + lex: Lexer::new(src), + stack: vec![], + dicts: vec![system.clone(), global.clone(), user.clone()], + system, + user, + global, + status: new_dict(), + error: new_dict(), + fonts, + resources: new_dict(), + depth: 0, + ops: 0, + allocated: 0, + seed: 1, + g, + saved: vec![], + out, + } + } + + /// Run the program to its end, its first `showpage` or `quit`. + pub fn run(&mut self) -> Res { + loop { + let Some(o) = self.lex.next()? else { return Ok(()) }; + match self.exec_token(o) { + Err(PsError::Quit) => return Ok(()), + r => r?, + } + } + } + + // ---------- the operand stack ---------- + + pub fn push(&mut self, o: Obj) -> Res { + if self.stack.len() >= MAX_STACK { + return Err(PsError::Limit("the operand stack overflowed")); + } + self.stack.push(o); + Ok(()) + } + + pub fn push_num(&mut self, v: f64) -> Res { + self.push(if v.is_finite() { Obj::Real(v) } else { Obj::Real(0.0) }) + } + + pub fn pop(&mut self) -> Res { + self.stack.pop().ok_or(PsError::Ps("stackunderflow", String::new())) + } + + pub fn pop_num(&mut self) -> Res { + match self.pop()?.as_num() { + Some(v) if v.is_finite() => Ok(v), + Some(_) => ps_err("undefinedresult", ""), + None => ps_err("typecheck", "a number"), + } + } + + /// `N` numbers, in the order they were pushed. + pub fn nums(&mut self) -> Res<[f64; N]> { + let mut out = [0.0; N]; + for slot in out.iter_mut().rev() { + *slot = self.pop_num()?; + } + Ok(out) + } + + pub fn pop_int(&mut self) -> Res { + match self.pop()? { + Obj::Int(i) => Ok(i), + Obj::Real(r) if r.fract() == 0.0 && r.abs() < 1e15 => Ok(r as i64), + _ => ps_err("typecheck", "an integer"), + } + } + + /// A count or index: a non-negative integer, at most [`MAX_ALLOC`]. + pub fn pop_count(&mut self) -> Res { + let i = self.pop_int()?; + if !(0..=MAX_ALLOC as i64).contains(&i) { + return ps_err("rangecheck", "a count"); + } + Ok(i as usize) + } + + pub fn pop_bool(&mut self) -> Res { + match self.pop()? { + Obj::Bool(b) => Ok(b), + _ => ps_err("typecheck", "a boolean"), + } + } + + pub fn pop_dict(&mut self) -> Res { + match self.pop()? { + Obj::Dict(d) => Ok(d), + _ => ps_err("typecheck", "a dictionary"), + } + } + + pub fn pop_str(&mut self) -> Res>>> { + match self.pop()? { + Obj::Str(s) => Ok(s), + _ => ps_err("typecheck", "a string"), + } + } + + /// An array or procedure. + pub fn pop_array(&mut self) -> Res>>> { + match self.pop()? { + Obj::Array { items, .. } => Ok(items), + _ => ps_err("typecheck", "an array"), + } + } + + /// A matrix operand (an array of six numbers). + pub fn pop_matrix(&mut self) -> Res { + let items = self.pop_array()?; + matrix_of(&items.borrow()).ok_or(PsError::Ps("typecheck", "a matrix".into())) + } + + /// Store `m` into the matrix operand `items` and push it back. + pub fn put_matrix(&mut self, items: Rc>>, m: Affine) -> Res { + { + let mut v = items.borrow_mut(); + if v.len() != 6 { + return ps_err("rangecheck", "a matrix"); + } + for (slot, c) in v.iter_mut().zip(m.as_coeffs()) { + *slot = Obj::Real(c); + } + } + self.push(Obj::Array { items, exec: false }) + } + + // ---------- names and running ---------- + + /// The value of name `name` on the dictionary stack. + pub fn lookup(&self, name: &Rc) -> Option { + let key = Key::Name(name.clone()); + self.dicts.iter().rev().find_map(|d| d.borrow().get(&key).cloned()) + } + + /// Count `bytes` the program makes against [`MAX_MEMORY`]. + pub fn alloc(&mut self, bytes: usize) -> Res { + self.allocated = self.allocated.saturating_add(bytes); + if self.allocated > MAX_MEMORY { + return Err(PsError::Limit("the program uses too much memory")); + } + Ok(()) + } + + fn tick(&mut self) -> Res { + self.ops += 1; + if self.ops > MAX_OPS { + return Err(PsError::Limit("the program runs too long")); + } + Ok(()) + } + + /// An object as read or met in a procedure body: executable names and operators run, the + /// rest (procedures too) is pushed. + fn exec_token(&mut self, o: Obj) -> Res { + self.tick()?; + match o { + Obj::Exec(name) => { + let v = self.lookup(&name).ok_or_else(|| PsError::Ps("undefined", name.to_string()))?; + self.call(v) + } + Obj::Op(op) => self.op(op), + o => self.push(o), + } + } + + /// Run a value (what a name stands for, or `exec`'s operand): procedures and operators run, + /// executable names are looked up, the rest is pushed. + pub fn call(&mut self, v: Obj) -> Res { + match v { + Obj::Array { items, exec: true } => self.run_proc(&items), + Obj::Op(op) => self.op(op), + Obj::Exec(name) => { + self.enter()?; + let r = match self.lookup(&name) { + Some(v) => self.call(v), + None => ps_err("undefined", &name), + }; + self.depth -= 1; + r + } + o => self.push(o), + } + } + + fn enter(&mut self) -> Res { + if self.depth >= MAX_DEPTH { + return Err(PsError::Limit("procedures nest too deeply")); + } + self.depth += 1; + Ok(()) + } + + fn run_proc(&mut self, items: &Rc>>) -> Res { + self.enter()?; + let r = self.run_items(items); + self.depth -= 1; + r + } + + fn run_items(&mut self, items: &Rc>>) -> Res { + let mut i = 0; + loop { + let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) }; + i += 1; + self.exec_token(o)?; + } + } + + /// Run `proc` as a loop body: `Ok(false)` when it ran `exit`. + fn body(&mut self, proc: &Obj) -> Res { + self.tick()?; + match self.call(proc.clone()) { + Ok(()) => Ok(true), + Err(PsError::Exit) => Ok(false), + Err(e) => Err(e), + } + } + + /// Run a procedure operand, executable or not (`exec`, `if`, loops). + fn pop_proc(&mut self) -> Res { + match self.pop()? { + Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }), + o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o), + _ => ps_err("typecheck", "a procedure"), + } + } + + // ---------- operators ---------- + + pub fn op(&mut self, op: Op) -> Res { + self.op_inner(op).map_err(|e| match e { + PsError::Ps(name, at) if at.is_empty() => PsError::Ps(name, op.name().to_string()), + e => e, + }) + } + + fn op_inner(&mut self, op: Op) -> Res { + use Op::*; + match op { + Pop => { + self.pop()?; + } + Exch => { + let (b, a) = (self.pop()?, self.pop()?); + self.push(b)?; + self.push(a)?; + } + Dup => { + let a = self.stack.last().cloned().ok_or(PsError::Ps("stackunderflow", String::new()))?; + self.push(a)?; + } + Copy => self.copy()?, + Index => { + let n = self.pop_count()?; + let at = self.stack.len().checked_sub(n + 1).ok_or(PsError::Ps("rangecheck", String::new()))?; + let o = self.stack.get(at).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?; + self.push(o)?; + } + Roll => { + let j = self.pop_int()?; + let n = self.pop_count()?; + let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("rangecheck", String::new()))?; + if n > 0 { + let k = j.rem_euclid(n as i64) as usize; + if let Some(s) = self.stack.get_mut(at..) { + s.rotate_right(k); + } + } + } + Clear => self.stack.clear(), + Count => self.push(Obj::Int(self.stack.len() as i64))?, + Mark | MarkBracket | DictBegin => self.push(Obj::Mark)?, + CloseArray => { + let items = self.pop_to_mark()?; + self.push(Obj::array(items))?; + } + DictEnd => { + let items = self.pop_to_mark()?; + let mut d = Dict::new(); + for pair in items.chunks(2) { + let [k, v] = pair else { return ps_err("rangecheck", ">>") }; + d.insert(k.key().ok_or(PsError::Ps("typecheck", ">>".into()))?, v.clone()); + } + self.push(Obj::dict(d))?; + } + ClearToMark => { + self.pop_to_mark()?; + } + CountToMark => { + let n = self.stack.iter().rev().position(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; + self.push(Obj::Int(n as i64))?; + } + Add | Sub | Mul => { + let (b, a) = (self.pop()?, self.pop()?); + let r = match (&a, &b) { + (Obj::Int(x), Obj::Int(y)) => match op { + Add => x.checked_add(*y).map(Obj::Int), + Sub => x.checked_sub(*y).map(Obj::Int), + _ => x.checked_mul(*y).map(Obj::Int), + }, + _ => None, + }; + let r = match r { + Some(r) => r, + None => { + let (x, y) = + (a.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, b.as_num().ok_or(PsError::Ps("typecheck", String::new()))?); + Obj::Real(match op { + Add => x + y, + Sub => x - y, + _ => x * y, + }) + } + }; + self.push(r)?; + } + Div => { + let [a, b] = self.nums()?; + if b == 0.0 { + return ps_err("undefinedresult", ""); + } + self.push_num(a / b)?; + } + Idiv | Mod => { + let (b, a) = (self.pop_int()?, self.pop_int()?); + let r = if op == Idiv { a.checked_div(b) } else { a.checked_rem(b) }; + self.push(Obj::Int(r.ok_or(PsError::Ps("undefinedresult", String::new()))?))?; + } + Neg | Abs => match self.pop()? { + Obj::Int(i) => self.push(Obj::Int(if op == Neg { i.saturating_neg() } else { i.saturating_abs() }))?, + Obj::Real(r) => self.push(Obj::Real(if op == Neg { -r } else { r.abs() }))?, + _ => return ps_err("typecheck", ""), + }, + Sqrt | Sin | Cos | Exp | Ln | Log | Atan => { + let r = match op { + Sqrt => self.pop_num()?.sqrt(), + Sin => self.pop_num()?.to_radians().sin(), + Cos => self.pop_num()?.to_radians().cos(), + Ln => self.pop_num()?.ln(), + Log => self.pop_num()?.log10(), + Exp => { + let [b, e] = self.nums()?; + b.powf(e) + } + _ => { + let [num, den] = self.nums()?; + num.atan2(den).to_degrees().rem_euclid(360.0) + } + }; + if !r.is_finite() { + return ps_err("undefinedresult", ""); + } + self.push(Obj::Real(r))?; + } + Round | Floor | Ceiling | Truncate => match self.pop()? { + Obj::Int(i) => self.push(Obj::Int(i))?, + Obj::Real(r) => self.push(Obj::Real(match op { + Round => (r + 0.5).floor(), + Floor => r.floor(), + Ceiling => r.ceil(), + _ => r.trunc(), + }))?, + _ => return ps_err("typecheck", ""), + }, + Cvi => { + let v = match self.pop()? { + Obj::Str(s) => String::from_utf8_lossy(&s.borrow()).trim().parse::().map_err(|_| PsError::Ps("typecheck", String::new()))?, + o => o.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, + }; + if !(v.is_finite() && v.abs() < 9e15) { + return ps_err("rangecheck", ""); + } + self.push(Obj::Int(v.trunc() as i64))?; + } + Cvr => { + let v = match self.pop()? { + Obj::Str(s) => String::from_utf8_lossy(&s.borrow()).trim().parse::().map_err(|_| PsError::Ps("typecheck", String::new()))?, + o => o.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, + }; + self.push_num(v)?; + } + Rand => { + // A small linear congruential generator: deterministic, like a fresh printer. + self.seed = self.seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + self.push(Obj::Int(i64::from(self.seed >> 1)))?; + } + Srand => self.seed = self.pop_int()? as u32, + Rrand => self.push(Obj::Int(i64::from(self.seed)))?, + Eq | Ne => { + let (b, a) = (self.pop()?, self.pop()?); + let same = equal(&a, &b); + self.push(Obj::Bool(same == (op == Eq)))?; + } + Gt | Ge | Lt | Le => { + let (b, a) = (self.pop()?, self.pop()?); + let ord = match (&a, &b) { + (Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()), + _ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)), + } + .ok_or(PsError::Ps("typecheck", String::new()))?; + let r = match op { + Gt => ord.is_gt(), + Ge => ord.is_ge(), + Lt => ord.is_lt(), + _ => ord.is_le(), + }; + self.push(Obj::Bool(r))?; + } + And | Or | Xor => { + let (b, a) = (self.pop()?, self.pop()?); + let r = match (a, b) { + (Obj::Bool(x), Obj::Bool(y)) => Obj::Bool(match op { + And => x && y, + Or => x || y, + _ => x ^ y, + }), + (Obj::Int(x), Obj::Int(y)) => Obj::Int(match op { + And => x & y, + Or => x | y, + _ => x ^ y, + }), + _ => return ps_err("typecheck", ""), + }; + self.push(r)?; + } + Not => match self.pop()? { + Obj::Bool(b) => self.push(Obj::Bool(!b))?, + Obj::Int(i) => self.push(Obj::Int(!i))?, + _ => return ps_err("typecheck", ""), + }, + Bitshift => { + let (s, v) = (self.pop_int()?, self.pop_int()?); + let r = if s >= 0 { v.checked_shl(s.min(63) as u32) } else { v.checked_shr((-s).min(63) as u32) }; + self.push(Obj::Int(r.unwrap_or(0)))?; + } + True => self.push(Obj::Bool(true))?, + False => self.push(Obj::Bool(false))?, + Null => self.push(Obj::Null)?, + Exec => { + let o = self.pop()?; + self.call(o)?; + } + If => { + let p = self.pop_proc()?; + if self.pop_bool()? { + self.call(p)?; + } + } + IfElse => { + let (no, yes) = (self.pop_proc()?, self.pop_proc()?); + let pick = if self.pop_bool()? { yes } else { no }; + self.call(pick)?; + } + For => { + let p = self.pop_proc()?; + let (limit, inc, init) = (self.pop()?, self.pop()?, self.pop()?); + match (&init, &inc, &limit) { + (Obj::Int(i0), Obj::Int(d), Obj::Int(l)) => { + let (mut i, d, l) = (*i0, *d, *l); + while (d >= 0 && i <= l) || (d < 0 && i >= l) { + self.push(Obj::Int(i))?; + if !self.body(&p)? { + break; + } + i = i.checked_add(d).ok_or(PsError::Ps("rangecheck", String::new()))?; + if d == 0 { + self.tick()?; + } + } + } + _ => { + let n = |o: &Obj| o.as_num().filter(|v| v.is_finite()).ok_or(PsError::Ps("typecheck", String::new())); + let (i0, d, l) = (n(&init)?, n(&inc)?, n(&limit)?); + let mut k = 0.0; + loop { + let i = i0 + d * k; + if !((d >= 0.0 && i <= l) || (d < 0.0 && i >= l)) { + break; + } + self.push(Obj::Real(i))?; + if !self.body(&p)? { + break; + } + k += 1.0; + } + } + } + } + Repeat => { + let p = self.pop_proc()?; + let n = self.pop_int()?; + if n < 0 { + return ps_err("rangecheck", ""); + } + for _ in 0..n { + if !self.body(&p)? { + break; + } + } + } + Loop => { + let p = self.pop_proc()?; + while self.body(&p)? {} + } + Exit => return Err(PsError::Exit), + Forall => self.forall()?, + Stopped => { + let p = self.pop()?; + let depth = self.depth; + match self.call(p) { + Ok(()) => self.push(Obj::Bool(false))?, + Err(PsError::Ps(..) | PsError::Stop | PsError::Exit) => { + self.depth = depth; + self.push(Obj::Bool(true))?; + } + Err(e) => return Err(e), + } + } + Stop => return Err(PsError::Stop), + Quit => return Err(PsError::Quit), + CountExecStack => self.push(Obj::Int(i64::from(self.depth) + 1))?, + Type => { + let o = self.pop()?; + self.push(Obj::Exec(Rc::from(o.type_name())))?; + } + Cvx => { + let o = match self.pop()? { + Obj::Array { items, .. } => Obj::Array { items, exec: true }, + Obj::Name(n) => Obj::Exec(n), + o => o, + }; + self.push(o)?; + } + Cvlit => { + let o = match self.pop()? { + Obj::Array { items, .. } => Obj::Array { items, exec: false }, + Obj::Exec(n) => Obj::Name(n), + o => o, + }; + self.push(o)?; + } + Xcheck => { + let o = self.pop()?; + self.push(Obj::Bool(matches!(o, Obj::Array { exec: true, .. } | Obj::Exec(_) | Obj::Op(_))))?; + } + Cvn => { + let s = self.pop_str()?; + let n: Rc = Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref()); + self.push(Obj::Name(n))?; + } + Cvs | Cvrs => { + let s = self.pop_str()?; + let text = if op == Cvrs { + let radix = self.pop_int()?; + let v = self.pop_num()?; + if !(2..=36).contains(&radix) { + return ps_err("rangecheck", ""); + } + radix_text(v as i64, radix as u32) + } else { + show_text(&self.pop()?) + }; + let n = text.len(); + if n > s.borrow().len() { + return ps_err("rangecheck", ""); + } + if let Some(d) = s.borrow_mut().get_mut(..n) { + d.copy_from_slice(text.as_bytes()); + } + self.push_interval(Obj::Str(s), 0, n)?; + } + ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => { + if matches!(op, SetPacking | SetGlobal | SetObjectFormat) { + self.pop()?; + } + } + Rcheck | Wcheck => { + self.pop()?; + self.push(Obj::Bool(true))?; + } + CurrentPacking | CurrentGlobal => self.push(Obj::Bool(false))?, + NewArray => { + let n = self.pop_count()?; + self.alloc(n * std::mem::size_of::())?; + self.push(Obj::array(vec![Obj::Null; n]))?; + } + PackedArray => { + let n = self.pop_count()?; + let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?; + let items = self.stack.split_off(at); + self.push(Obj::array(items))?; + } + NewString => { + let n = self.pop_count()?; + self.alloc(n)?; + self.push(Obj::string(vec![0; n]))?; + } + Length => { + let n = match self.pop()? { + Obj::Array { items, .. } => items.borrow().len(), + Obj::Str(s) => s.borrow().len(), + Obj::Dict(d) => d.borrow().len(), + Obj::Name(n) | Obj::Exec(n) => n.len(), + _ => return ps_err("typecheck", ""), + }; + self.push(Obj::Int(n as i64))?; + } + MaxLength => { + let d = self.pop_dict()?; + let n = d.borrow().len().max(d.borrow().capacity()); + self.push(Obj::Int(n as i64))?; + } + Get => { + let k = self.pop()?; + let o = match self.pop()? { + Obj::Array { items, .. } => { + let i = index(&k)?; + items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))? + } + Obj::Str(s) => { + let i = index(&k)?; + Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?)) + } + Obj::Dict(d) => { + let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let v = d.borrow().get(&key).cloned(); + v.ok_or_else(|| PsError::Ps("undefined", show_text(&k)))? + } + _ => return ps_err("typecheck", ""), + }; + self.push(o)?; + } + Put => { + let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?); + match c { + Obj::Array { items, .. } => { + let i = index(&k)?; + *items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v; + } + Obj::Str(s) => { + let i = index(&k)?; + let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?; + *s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8; + } + Obj::Dict(d) => { + let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?; + insert(&d, key, v)?; + } + _ => return ps_err("typecheck", ""), + } + } + GetInterval => { + let n = self.pop_count()?; + let at = self.pop_count()?; + let o = self.pop()?; + self.push_interval(o, at, n)?; + } + PutInterval => { + let src = self.pop()?; + let at = self.pop_count()?; + match (self.pop()?, src) { + (Obj::Array { items, .. }, Obj::Array { items: from, .. }) => { + let from = from.borrow().clone(); + let mut v = items.borrow_mut(); + let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?; + d.clone_from_slice(&from); + } + (Obj::Str(s), Obj::Str(from)) => { + let from = from.borrow().clone(); + let mut v = s.borrow_mut(); + let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?; + d.copy_from_slice(&from); + } + _ => return ps_err("typecheck", ""), + } + } + Aload => { + let items = self.pop_array()?; + let all = items.borrow().clone(); + for o in all { + self.push(o)?; + } + self.push(Obj::Array { items, exec: false })?; + } + Astore => { + let items = self.pop_array()?; + let n = items.borrow().len(); + let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?; + *items.borrow_mut() = self.stack.split_off(at); + self.push(Obj::Array { items, exec: false })?; + } + Search | AnchorSearch => { + let seek = self.pop_str()?.borrow().clone(); + let s = self.pop_str()?; + let text = s.borrow().clone(); + let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) }; + match at { + Some(i) => { + let o = Obj::Str(s); + if op == Search { + self.push_interval(o.clone(), i + seek.len(), text.len() - i - seek.len())?; + self.push_interval(o.clone(), i, seek.len())?; + self.push_interval(o, 0, i)?; + } else { + self.push_interval(o.clone(), seek.len(), text.len() - seek.len())?; + self.push_interval(o, 0, seek.len())?; + } + self.push(Obj::Bool(true))?; + } + None => { + self.push(Obj::Str(s))?; + self.push(Obj::Bool(false))?; + } + } + } + NewDict => { + self.pop_count()?; + self.push(Obj::dict(Default::default()))?; + } + Begin => { + let d = self.pop_dict()?; + if self.dicts.len() >= MAX_DICTS { + return Err(PsError::Limit("the dictionary stack overflowed")); + } + self.dicts.push(d); + } + End => { + // systemdict, globaldict and userdict stay. + if self.dicts.len() <= 3 { + return ps_err("dictstackunderflow", "end"); + } + self.dicts.pop(); + } + Def => { + let v = self.pop()?; + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?; + insert(&d, k, v)?; + } + Load => { + let k = self.pop()?; + let name = k.text().ok_or(PsError::Ps("typecheck", String::new()))?; + let v = self.lookup(&name).ok_or_else(|| PsError::Ps("undefined", name.to_string()))?; + self.push(v)?; + } + Store => { + let v = self.pop()?; + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned(); + let d = d.ok_or(PsError::Ps("dictstackunderflow", String::new()))?; + insert(&d, k, v)?; + } + Known => { + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let d = self.pop_dict()?; + let known = d.borrow().contains_key(&k); + self.push(Obj::Bool(known))?; + } + Where => { + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + match self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).cloned() { + Some(d) => { + self.push(Obj::Dict(d))?; + self.push(Obj::Bool(true))?; + } + None => self.push(Obj::Bool(false))?, + } + } + Undef => { + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?; + self.pop_dict()?.borrow_mut().remove(&k); + } + CurrentDict => { + let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?; + self.push(Obj::Dict(d))?; + } + CountDictStack => self.push(Obj::Int(self.dicts.len() as i64))?, + SystemDict => self.push(Obj::Dict(self.system.clone()))?, + UserDict => self.push(Obj::Dict(self.user.clone()))?, + GlobalDict => self.push(Obj::Dict(self.global.clone()))?, + StatusDict => self.push(Obj::Dict(self.status.clone()))?, + ErrorDict | DollarError => self.push(Obj::Dict(self.error.clone()))?, + Bind => { + let o = self.pop()?; + if let Obj::Array { items, .. } = &o { + self.bind(items, 0); + } + self.push(o)?; + } + Save => { + self.gsave()?; + self.push(Obj::Save(self.saved.len()))?; + } + Restore => { + let Obj::Save(level) = self.pop()? else { return ps_err("typecheck", "") }; + while self.saved.len() >= level && !self.saved.is_empty() { + self.grestore(); + } + } + VmStatus => { + for v in [0, 1 << 20, 1 << 30] { + self.push(Obj::Int(v))?; + } + } + LanguageLevel => self.push(Obj::Int(3))?, + Version => self.push(Obj::string(b"3010".to_vec()))?, + Product => self.push(Obj::string(b"VectorCraft".to_vec()))?, + RealTime | UserTime => self.push(Obj::Int(0))?, + Print | EqPrint | EqEqPrint => { + self.pop()?; + } + Pstack | Stack | Flush => {} + _ => self.graphics_op(op)?, + } + Ok(()) + } + + /// The objects above the topmost mark, which is removed. + fn pop_to_mark(&mut self) -> Res> { + let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?; + let items = self.stack.split_off(at + 1); + self.stack.pop(); + Ok(items) + } + + /// `copy`: the top `n` operands again, or a composite into another. + fn copy(&mut self) -> Res { + match self.pop()? { + Obj::Int(n) => { + let n = usize::try_from(n).map_err(|_| PsError::Ps("rangecheck", String::new()))?; + let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?; + self.alloc(n * std::mem::size_of::())?; + let top: Vec = self.stack.get(at..).unwrap_or_default().to_vec(); + for o in top { + self.push(o)?; + } + } + Obj::Array { items, exec } => { + let from = self.pop_array()?.borrow().clone(); + let n = from.len(); + { + let mut v = items.borrow_mut(); + v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from); + } + self.push_interval(Obj::Array { items, exec }, 0, n)?; + } + Obj::Str(s) => { + let from = self.pop_str()?.borrow().clone(); + let n = from.len(); + s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from); + self.push_interval(Obj::Str(s), 0, n)?; + } + Obj::Dict(d) => { + let from = self.pop_dict()?.borrow().clone(); + d.borrow_mut().extend(from); + self.push(Obj::Dict(d))?; + } + _ => return ps_err("typecheck", "copy"), + } + Ok(()) + } + + /// `n` items of an array or string from `at` (a copy: shared storage isn't modelled). + fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res { + let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?; + self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::() })?; + let r = match o { + Obj::Array { items, exec } => { + let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec(); + Obj::Array { items: Rc::new(RefCell::new(v)), exec } + } + Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()), + _ => return ps_err("typecheck", "getinterval"), + }; + self.push(r) + } + + fn forall(&mut self) -> Res { + let p = self.pop_proc()?; + match self.pop()? { + Obj::Array { items, .. } => { + let mut i = 0; + while let Some(o) = items.borrow().get(i).cloned() { + i += 1; + self.push(o)?; + if !self.body(&p)? { + break; + } + } + } + Obj::Str(s) => { + let bytes = s.borrow().clone(); + for b in bytes { + self.push(Obj::Int(i64::from(b)))?; + if !self.body(&p)? { + break; + } + } + } + Obj::Dict(d) => { + let pairs: Vec<(Key, Obj)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in pairs { + self.push(k.obj())?; + self.push(v)?; + if !self.body(&p)? { + break; + } + } + } + _ => return ps_err("typecheck", "forall"), + } + Ok(()) + } + + /// Replace the names in a procedure that stand for operators by the operators, down through + /// nested procedures. + fn bind(&mut self, items: &Rc>>, depth: u32) { + if depth > 64 { + return; + } + let n = items.borrow().len(); + for i in 0..n { + let o = items.borrow().get(i).cloned(); + match o { + Some(Obj::Exec(name)) => { + if let Some(Obj::Op(op)) = self.lookup(&name) + && let Some(slot) = items.borrow_mut().get_mut(i) + { + *slot = Obj::Op(op); + } + } + Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1), + _ => {} + } + } + } + + // ---------- resources ---------- + + /// The resources of `category`. + pub fn category(&mut self, category: &Obj) -> Res { + let k = category.key().ok_or(PsError::Ps("typecheck", String::new()))?; + let mut all = self.resources.borrow_mut(); + Ok(all.entry(k).or_insert_with(|| Obj::Dict(new_dict())).clone()).and_then(|o| match o { + Obj::Dict(d) => Ok(d), + _ => ps_err("typecheck", ""), + }) + } +} + +/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries. +fn insert(d: &DictRef, k: Key, v: Obj) -> Res { + let mut d = d.borrow_mut(); + if d.len() >= MAX_ALLOC && !d.contains_key(&k) { + return Err(PsError::Limit("a dictionary grew too large")); + } + d.insert(k, v); + Ok(()) +} + +/// An array or string index. +fn index(k: &Obj) -> Res { + match *k { + Obj::Int(i) if i >= 0 => Ok(i as usize), + _ => ps_err("rangecheck", "an index"), + } +} + +/// `eq`: numbers by value, strings and names by text, composites by identity. +fn equal(a: &Obj, b: &Obj) -> bool { + match (a, b) { + (Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true, + (Obj::Bool(x), Obj::Bool(y)) => x == y, + (Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y), + (Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y), + (Obj::Op(x), Obj::Op(y)) => x == y, + _ => match (a.as_num(), b.as_num()) { + (Some(x), Some(y)) => x == y, + _ => matches!((a.text(), b.text()), (Some(x), Some(y)) if x == y), + }, + } +} + +/// The text `cvs` writes for `o`. +fn show_text(o: &Obj) -> String { + match o { + Obj::Int(i) => i.to_string(), + Obj::Real(r) => { + let s = format!("{r}"); + if s.contains('.') || s.contains('e') { s } else { format!("{s}.0") } + } + Obj::Bool(b) => b.to_string(), + Obj::Name(n) | Obj::Exec(n) => n.to_string(), + Obj::Str(s) => String::from_utf8_lossy(&s.borrow()).into_owned(), + Obj::Op(op) => op.name().to_string(), + _ => "--nostringval--".into(), + } +} + +/// `v` in `radix`, as `cvrs` writes it (negative numbers as their 32-bit two's complement). +fn radix_text(v: i64, radix: u32) -> String { + let mut n = if v < 0 { u64::from(v as u32) } else { v as u64 }; + if n == 0 { + return "0".into(); + } + let mut digits = vec![]; + while n > 0 { + digits.push(std::char::from_digit((n % u64::from(radix)) as u32, radix).unwrap_or('0').to_ascii_uppercase()); + n /= u64::from(radix); + } + digits.iter().rev().collect() +} + +/// A matrix from six numbers. +pub(crate) fn matrix_of(items: &[Obj]) -> Option { + let [a, b, c, d, e, f] = items else { return None }; + let v = [a, b, c, d, e, f].map(|o| o.as_num().filter(|v| v.is_finite())); + let [Some(a), Some(b), Some(c), Some(d), Some(e), Some(f)] = v else { return None }; + Some(Affine::new([a, b, c, d, e, f])) +} + +/// A matrix as an array. +pub(crate) fn matrix_obj(m: Affine) -> Obj { + Obj::array(m.as_coeffs().iter().map(|v| Obj::Real(*v)).collect()) +} diff --git a/crates/eps/src/import/lex.rs b/crates/eps/src/import/lex.rs new file mode 100644 index 00000000..24b74023 --- /dev/null +++ b/crates/eps/src/import/lex.rs @@ -0,0 +1,289 @@ +//! The scanner: PostScript source into objects (numbers, names, strings, procedures), with the +//! position the program's own data (`currentfile`) is read from. + +use std::rc::Rc; + +use super::obj::{Obj, PsError, Res}; +use crate::ps; + +/// Deepest procedure nesting the scanner reads. +const MAX_NESTING: usize = 256; + +pub(crate) struct Lexer<'a> { + src: &'a [u8], + pos: usize, + /// The whitespace character that ended the last token is still unread (data the program + /// reads from itself starts after it). + fresh: bool, +} + +fn is_space(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0') +} + +fn is_delim(b: u8) -> bool { + matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +fn syntax(what: &str) -> Res { + Err(PsError::Ps("syntaxerror", what.to_string())) +} + +impl<'a> Lexer<'a> { + pub fn new(src: &'a [u8]) -> Self { + Self { src, pos: 0, fresh: false } + } + + /// What is left to read. + pub fn rest(&self) -> &'a [u8] { + self.src.get(self.pos..).unwrap_or_default() + } + + /// What is left to read as data: after the whitespace that ended the last token (a CR LF + /// pair counts as one). + pub fn data(&mut self) -> &'a [u8] { + if std::mem::take(&mut self.fresh) { + match self.peek() { + Some(b'\r') => { + self.pos += 1; + if self.peek() == Some(b'\n') { + self.pos += 1; + } + } + Some(b) if is_space(b) => self.pos += 1, + _ => {} + } + } + self.rest() + } + + /// Skip `n` bytes (the data a filter or `readstring` took). + pub fn advance(&mut self, n: usize) { + self.fresh = false; + self.pos = self.pos.saturating_add(n).min(self.src.len()); + } + + fn peek(&self) -> Option { + self.src.get(self.pos).copied() + } + + fn skip_space(&mut self) { + while let Some(b) = self.peek() { + if is_space(b) { + self.pos += 1; + } else if b == b'%' { + while let Some(c) = self.peek() { + if c == b'\n' || c == b'\r' { + break; + } + self.pos += 1; + } + } else { + break; + } + } + } + + /// The next object at the top level (`None` at the end of the file). + pub fn next(&mut self) -> Res> { + let t = self.token(0); + self.fresh = true; + t + } + + fn token(&mut self, depth: usize) -> Res> { + self.skip_space(); + let Some(b) = self.peek() else { return Ok(None) }; + self.pos += 1; + Ok(Some(match b { + b'(' => Obj::string(self.string()?), + b'<' => match self.peek() { + Some(b'<') => { + self.pos += 1; + Obj::Exec(Rc::from("<<")) + } + Some(b'~') => { + self.pos += 1; + Obj::string(self.ascii85()?) + } + _ => Obj::string(self.hex()?), + }, + b'>' => { + if self.peek() != Some(b'>') { + return syntax(">"); + } + self.pos += 1; + Obj::Exec(Rc::from(">>")) + } + b'[' => Obj::Exec(Rc::from("[")), + b']' => Obj::Exec(Rc::from("]")), + b'{' => { + if depth >= MAX_NESTING { + return Err(PsError::Limit("procedures nested too deeply")); + } + let mut items = vec![]; + loop { + self.skip_space(); + match self.peek() { + None => return syntax("{"), + Some(b'}') => { + self.pos += 1; + break; + } + _ => match self.token(depth + 1)? { + Some(o) => items.push(o), + None => return syntax("{"), + }, + } + } + Obj::proc(items) + } + b'}' | b')' => return syntax(&char::from(b).to_string()), + b'/' => { + // `//name` is looked up when run (rather than when read). + let immediate = self.peek() == Some(b'/'); + if immediate { + self.pos += 1; + } + let name = self.word(); + if immediate { Obj::Exec(Rc::from(name.as_str())) } else { Obj::Name(Rc::from(name.as_str())) } + } + _ => { + self.pos -= 1; + let w = self.word(); + number(&w).unwrap_or_else(|| Obj::Exec(Rc::from(w.as_str()))) + } + })) + } + + /// A regular token's characters. + fn word(&mut self) -> String { + let start = self.pos; + while let Some(b) = self.peek() { + if is_space(b) || is_delim(b) { + break; + } + self.pos += 1; + } + String::from_utf8_lossy(self.src.get(start..self.pos).unwrap_or_default()).into_owned() + } + + /// A literal string after its `(`. + fn string(&mut self) -> Res> { + let mut out = vec![]; + let mut depth = 0usize; + loop { + let Some(b) = self.peek() else { return syntax("(") }; + self.pos += 1; + match b { + b'(' => { + depth += 1; + out.push(b); + } + b')' if depth == 0 => return Ok(out), + b')' => { + depth -= 1; + out.push(b); + } + b'\\' => { + let Some(e) = self.peek() else { return syntax("(") }; + self.pos += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(8), + b'f' => out.push(12), + b'\r' => { + if self.peek() == Some(b'\n') { + self.pos += 1; + } + } + b'\n' => {} + b'0'..=b'7' => { + let mut v = u32::from(e - b'0'); + for _ in 0..2 { + match self.peek() { + Some(d @ b'0'..=b'7') => { + v = v * 8 + u32::from(d - b'0'); + self.pos += 1; + } + _ => break, + } + } + out.push((v & 0xff) as u8); + } + other => out.push(other), + } + } + _ => out.push(b), + } + } + } + + /// A hexadecimal string after its `<`. + fn hex(&mut self) -> Res> { + let end = self.rest().iter().position(|b| *b == b'>').ok_or(PsError::Ps("syntaxerror", "<".into()))?; + let body = self.rest().get(..end).unwrap_or_default(); + let out = hex_decode(body).ok_or(PsError::Ps("syntaxerror", "<".into()))?; + self.advance(end + 1); + Ok(out) + } + + /// An ASCII85 string after its `<~`. + fn ascii85(&mut self) -> Res> { + let end = find(self.rest(), b"~>").ok_or(PsError::Ps("syntaxerror", "<~".into()))?; + let text = String::from_utf8_lossy(self.rest().get(..end + 2).unwrap_or_default()).into_owned(); + self.advance(end + 2); + ps::ascii85_decode(&text).ok_or(PsError::Ps("syntaxerror", "<~".into())) + } +} + +/// Where `needle` first starts in `hay`. +pub(crate) fn find(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Hexadecimal digits (whitespace ignored, an odd last digit padded with 0); `None` on others. +pub(crate) fn hex_decode(body: &[u8]) -> Option> { + let mut out = Vec::with_capacity(body.len() / 2); + let mut high: Option = None; + for &b in body { + if is_space(b) { + continue; + } + let v = char::from(b).to_digit(16)? as u8; + match high.take() { + Some(h) => out.push(h << 4 | v), + None => high = Some(v), + } + } + if let Some(h) = high { + out.push(h << 4); + } + Some(out) +} + +/// A number token (`12`, `-3.5`, `.5`, `1e-3`, `16#ff`), or `None`. +fn number(w: &str) -> Option { + let first = w.bytes().next()?; + if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) { + return None; + } + if let Ok(i) = w.parse::() { + return Some(Obj::Int(i)); + } + if let Some((base, digits)) = w.split_once('#') { + let base: u32 = base.parse().ok().filter(|b| (2..=36).contains(b))?; + return i64::from_str_radix(digits, base).ok().map(Obj::Int); + } + // Rust reads `inf` and `nan`; PostScript doesn't. + if w.bytes().any(|b| b.is_ascii_alphabetic() && !matches!(b, b'e' | b'E')) { + return None; + } + let v: f64 = w.parse().ok()?; + v.is_finite().then_some(Obj::Real(v)) +} diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs new file mode 100644 index 00000000..127046b4 --- /dev/null +++ b/crates/eps/src/import/mod.rs @@ -0,0 +1,210 @@ +//! EPS and PostScript import: a small clean-room PostScript interpreter that turns what a file +//! draws into document objects. +//! +//! - `lex`: the scanner; `obj`: objects, operators and errors; `interp`: the stacks, running +//! procedures, and the operators that don't draw. +//! - `graphics`: the graphics state, coordinates, paths, colours (grey, RGB, CMYK, spot inks as +//! spot swatches at a tint, indexed), painting, clipping, and axial and radial shadings as +//! gradients. +//! - `data`: data the program reads from itself through decoding filters, and images. +//! - `text`: fonts by name and type as point type. +//! +//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page +//! of a PostScript file without one. A file whose program can't be read (an operator the +//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its TIFF +//! preview, with a warning; without a preview, what was drawn before the error is kept (with a +//! warning), else the file is refused. + +mod data; +mod graphics; +mod interp; +mod lex; +mod obj; +mod text; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::Swatch; +use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +use graphics::{GState, Out}; +use interp::Interp; +use obj::PsError; + +pub use text::family_style; + +/// The page of a PostScript file without a bounding box (US Letter). +const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; +/// Largest page side read (points). +const MAX_SIDE: f64 = 1e5; + +/// A read file. +#[derive(Debug)] +pub struct Imported { + pub document: Document, + pub warnings: Vec, + /// The document shows the file's preview: its PostScript couldn't be read. + pub preview: bool, +} + +/// The DSC comments the reader uses. +#[derive(Debug, Default, PartialEq)] +struct Dsc { + /// `llx lly urx ury` in PostScript's default space. + bbox: Option<[f64; 4]>, + pages: Option, +} + +/// The four numbers of a bounding box comment's value, when it has them and they make a box. +fn box_of(v: &str) -> Option<[f64; 4]> { + let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); + let [x0, y0, x1, y1] = n.as_slice() else { return None }; + let b = [*x0, *y0, *x1, *y1]; + (b.iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) && x1 > x0 && y1 > y0).then_some(b) +} + +impl Dsc { + /// The comments of `ps`: from the header, or (`(atend)`) the last ones in the file. + fn read(ps: &[u8]) -> Self { + let mut d = Dsc::default(); + let (mut bbox, mut hires, mut atend) = (None, None, false); + let mut header = true; + for raw in ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()) { + // Past the header only comments can matter. + if !header && !raw.starts_with(b"%%") { + continue; + } + let line = String::from_utf8_lossy(raw); + let line = line.trim_end(); + if header && (line.starts_with("%%EndComments") || !line.starts_with('%')) { + header = false; + if !atend { + break; + } + } + let value = |key: &str| line.strip_prefix(key).map(str::trim); + if let Some(v) = value("%%HiResBoundingBox:") { + if header || atend { + hires = box_of(v).or(hires); + } + } else if let Some(v) = value("%%BoundingBox:") { + if v.starts_with("(atend)") { + atend = true; + } else if header || atend { + bbox = box_of(v).or(bbox); + } + } else if let Some(v) = value("%%Pages:") + && header + { + d.pages = v.split_whitespace().next().and_then(|n| n.parse().ok()); + } + } + d.bbox = hires.or(bbox); + d + } +} + +/// What went wrong, for the user. +fn reason(e: &PsError) -> String { + match e { + PsError::Ps("undefined", at) => format!("it uses `{at}`, which VectorCraft's PostScript reader doesn't know"), + PsError::Ps(name, at) if at.is_empty() => format!("a PostScript error ({name})"), + PsError::Ps(name, at) => format!("a PostScript error ({name} in `{at}`)"), + PsError::Limit(what) => what.to_string(), + PsError::Exit | PsError::Stop => "the program stopped with an error".into(), + PsError::Quit => "the program ended early".into(), + } +} + +/// Read an EPS or PostScript file (its first page). +pub fn import(bytes: &[u8]) -> Result { + let (ps, tiff) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?; + if !ps.starts_with(b"%!") { + return Err("this is not a PostScript file".into()); + } + let dsc = Dsc::read(ps); + let [llx, lly, urx, ury] = dsc.bbox.unwrap_or(LETTER); + let frame = Rect::new(0.0, 0.0, urx - llx, ury - lly); + let mut doc = Document::new(frame.width(), frame.height()); + doc.layers.clear(); + let page = Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, frame)); + let result = it.run(); + let mut out = it.out; + if let Some(n) = dsc.pages.filter(|n| *n > 1) { + out.warn(&format!("only the first of the file's {n} pages was read")); + } + let drew = !out.drawn.is_empty(); + let why = match (&result, drew) { + (Ok(()), true) => return Ok(finish(out)), + (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), + (Err(e), _) => reason(e), + }; + if let Some(doc) = tiff.and_then(|t| preview(t, frame)) { + let warnings = vec![format!("this file's PostScript couldn't be read ({why}): its preview was placed instead, at screen resolution")]; + return Ok(Imported { document: doc, warnings, preview: true }); + } + if drew { + out.warnings.insert(0, format!("the file was read up to an error ({why}): what follows it was left out")); + return Ok(finish(out)); + } + match result { + Ok(()) => { + out.warn("the file draws nothing"); + Ok(finish(out)) + } + Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e))), + } +} + +/// The document of what `out` drew: one layer, clipped art in clipping groups, the spot inks as +/// spot swatches, in CMYK when most process colours were. +fn finish(mut out: Out) -> Imported { + let drawn = std::mem::take(&mut out.drawn); + let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn); + if !out.shadings.is_empty() { + graphics::collapse(&mut children, &out.shadings); + } + let mut doc = out.doc; + if out.cmyk > out.rgb { + doc.color_mode = ColorMode::Cmyk; + (doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model()); + } + for (name, color) in out.spots { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { name, paint: Paint::solid(color), global: true, spot: true }); + } + } + let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = children; + } + doc.layers = vec![Arc::new(layer)]; + Imported { document: doc, warnings: out.warnings, preview: false } +} + +/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it. +fn preview(tiff: &[u8], frame: Rect) -> Option { + let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + if w == 0 || h == 0 { + return None; + } + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let mut doc = Document::new(frame.width(), frame.height()); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); + let mut n = Node::new(doc.alloc_id(), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })); + n.name = Some("Preview".into()); + let layer = doc.layers.first().map(|l| l.id)?; + doc.insert(Some(layer), 0, n).ok()?; + Some(doc) +} diff --git a/crates/eps/src/import/obj.rs b/crates/eps/src/import/obj.rs new file mode 100644 index 00000000..59f71e1d --- /dev/null +++ b/crates/eps/src/import/obj.rs @@ -0,0 +1,259 @@ +//! The PostScript objects the interpreter works with, its operators and its errors. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use super::data::Stream; +use super::graphics::GState; + +/// A dictionary (keys are names; strings and integral reals are keyed as their name or number). +pub(crate) type Dict = HashMap; + +/// A shared, mutable dictionary. +pub(crate) type DictRef = Rc>; + +/// A dictionary key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Key { + Name(Rc), + Int(i64), + Bool(bool), +} + +#[derive(Clone, Debug)] +pub(crate) enum Obj { + Null, + Bool(bool), + Int(i64), + Real(f64), + /// A literal name (`/name`). + Name(Rc), + /// An executable name: looked up and run. + Exec(Rc), + Str(Rc>>), + /// An array; executable ones are procedures. + Array { + items: Rc>>, + exec: bool, + }, + Dict(DictRef), + Op(Op), + Mark, + File(Rc>), + /// A `save` level (the graphics state depth it restores to). + Save(usize), + /// A graphics state object (`gstate`, `currentgstate`). + GState(Rc), +} + +impl Obj { + pub fn name(s: &str) -> Self { + Self::Name(Rc::from(s)) + } + + pub fn string(bytes: Vec) -> Self { + Self::Str(Rc::new(RefCell::new(bytes))) + } + + pub fn array(items: Vec) -> Self { + Self::Array { items: Rc::new(RefCell::new(items)), exec: false } + } + + pub fn proc(items: Vec) -> Self { + Self::Array { items: Rc::new(RefCell::new(items)), exec: true } + } + + pub fn dict(d: Dict) -> Self { + Self::Dict(Rc::new(RefCell::new(d))) + } + + /// The number this is (integers and reals). + pub fn as_num(&self) -> Option { + match *self { + Self::Int(i) => Some(i as f64), + Self::Real(r) => Some(r), + _ => None, + } + } + + /// The name or string text (names and strings, as dictionary keys are). + pub fn text(&self) -> Option> { + match self { + Self::Name(n) | Self::Exec(n) => Some(n.clone()), + Self::Str(s) => Some(Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref())), + _ => None, + } + } + + /// The key this is in a dictionary. + pub fn key(&self) -> Option { + match self { + Self::Int(i) => Some(Key::Int(*i)), + Self::Real(r) if r.fract() == 0.0 && r.abs() < 1e15 => Some(Key::Int(*r as i64)), + Self::Bool(b) => Some(Key::Bool(*b)), + Self::Op(op) => Some(Key::Name(Rc::from(op.name()))), + o => o.text().map(Key::Name), + } + } + + /// The items of an array or procedure. + pub fn items(&self) -> Option<&Rc>>> { + match self { + Self::Array { items, .. } => Some(items), + _ => None, + } + } + + /// The name `type` returns. + pub fn type_name(&self) -> &'static str { + match self { + Self::Null => "nulltype", + Self::Bool(_) => "booleantype", + Self::Int(_) => "integertype", + Self::Real(_) => "realtype", + Self::Name(_) | Self::Exec(_) => "nametype", + Self::Str(_) => "stringtype", + Self::Array { .. } => "arraytype", + Self::Dict(_) => "dicttype", + Self::Op(_) => "operatortype", + Self::Mark => "marktype", + Self::File(_) => "filetype", + Self::Save(_) => "savetype", + Self::GState(_) => "gstatetype", + } + } +} + +impl Key { + pub fn name(s: &str) -> Self { + Self::Name(Rc::from(s)) + } + + /// The key as an object (`forall` on a dictionary). + pub fn obj(&self) -> Obj { + match self { + Self::Name(n) => Obj::Name(n.clone()), + Self::Int(i) => Obj::Int(*i), + Self::Bool(b) => Obj::Bool(*b), + } + } +} + +/// Why interpretation stopped. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PsError { + /// A PostScript error (`stopped` catches it): its name and the operator or name it was raised + /// at. + Ps(&'static str, String), + /// `exit` outside a loop body is propagated up to the loop. + Exit, + /// `stop`. + Stop, + /// `quit`, or the end of the page: interpretation is over, without an error. + Quit, + /// A limit was reached (operations, nesting, memory): the file is abandoned. + Limit(&'static str), +} + +pub(crate) type Res = Result; + +/// A PostScript error raised at `at`. +pub(crate) fn ps_err(name: &'static str, at: &str) -> Res { + Err(PsError::Ps(name, at.to_string())) +} + +macro_rules! ops { + ($($v:ident = $n:literal),* $(,)?) => { + /// The operators the interpreter knows. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum Op { $($v),* } + + impl Op { + /// Every operator with its name (`systemdict`). + pub const ALL: &'static [(Op, &'static str)] = &[$((Op::$v, $n)),*]; + + pub fn name(self) -> &'static str { + match self { $(Op::$v => $n),* } + } + } + }; +} + +ops! { + // Stack. + Pop = "pop", Exch = "exch", Dup = "dup", Copy = "copy", Index = "index", Roll = "roll", Clear = "clear", Count = "count", + Mark = "mark", MarkBracket = "[", CloseArray = "]", DictBegin = "<<", DictEnd = ">>", ClearToMark = "cleartomark", + CountToMark = "counttomark", + // Arithmetic. + Add = "add", Sub = "sub", Mul = "mul", Div = "div", Idiv = "idiv", Mod = "mod", Neg = "neg", Abs = "abs", Sqrt = "sqrt", + Sin = "sin", Cos = "cos", Atan = "atan", Exp = "exp", Ln = "ln", Log = "log", Round = "round", Floor = "floor", + Ceiling = "ceiling", Truncate = "truncate", Cvi = "cvi", Cvr = "cvr", Rand = "rand", Srand = "srand", Rrand = "rrand", + // Relations and logic. + Eq = "eq", Ne = "ne", Gt = "gt", Ge = "ge", Lt = "lt", Le = "le", And = "and", Or = "or", Xor = "xor", Not = "not", + Bitshift = "bitshift", True = "true", False = "false", Null = "null", + // Control. + Exec = "exec", If = "if", IfElse = "ifelse", For = "for", Repeat = "repeat", Loop = "loop", Exit = "exit", + Forall = "forall", Stopped = "stopped", Stop = "stop", Quit = "quit", CountExecStack = "countexecstack", + // Types. + Type = "type", Cvx = "cvx", Cvlit = "cvlit", Xcheck = "xcheck", Cvn = "cvn", Cvs = "cvs", Cvrs = "cvrs", + ReadOnly = "readonly", ExecuteOnly = "executeonly", NoAccess = "noaccess", Rcheck = "rcheck", Wcheck = "wcheck", + // Composites. + NewArray = "array", PackedArray = "packedarray", SetPacking = "setpacking", CurrentPacking = "currentpacking", + Length = "length", Get = "get", Put = "put", GetInterval = "getinterval", PutInterval = "putinterval", + Aload = "aload", Astore = "astore", NewString = "string", Search = "search", AnchorSearch = "anchorsearch", + // Dictionaries. + NewDict = "dict", MaxLength = "maxlength", Begin = "begin", End = "end", Def = "def", Load = "load", Store = "store", + Known = "known", Where = "where", Undef = "undef", CurrentDict = "currentdict", CountDictStack = "countdictstack", + SystemDict = "systemdict", UserDict = "userdict", GlobalDict = "globaldict", StatusDict = "statusdict", + ErrorDict = "errordict", DollarError = "$error", Bind = "bind", + // Virtual memory and the environment. + Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus", + LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime", + Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush", + // Graphics state. + Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics", + GStateNew = "gstate", CurrentGState = "currentgstate", SetGState = "setgstate", + SetLineWidth = "setlinewidth", CurrentLineWidth = "currentlinewidth", SetLineCap = "setlinecap", + CurrentLineCap = "currentlinecap", SetLineJoin = "setlinejoin", CurrentLineJoin = "currentlinejoin", + SetMiterLimit = "setmiterlimit", CurrentMiterLimit = "currentmiterlimit", SetDash = "setdash", CurrentDash = "currentdash", + SetGray = "setgray", CurrentGray = "currentgray", SetRgbColor = "setrgbcolor", CurrentRgbColor = "currentrgbcolor", + SetHsbColor = "sethsbcolor", SetCmykColor = "setcmykcolor", CurrentCmykColor = "currentcmykcolor", + SetColorSpace = "setcolorspace", CurrentColorSpace = "currentcolorspace", SetColor = "setcolor", CurrentColor = "currentcolor", + SetPattern = "setpattern", MakePattern = "makepattern", SetOverprint = "setoverprint", CurrentOverprint = "currentoverprint", + // Device settings without an effect on the art. + SetFlat = "setflat", CurrentFlat = "currentflat", SetScreen = "setscreen", SetHalftone = "sethalftone", + SetTransfer = "settransfer", SetColorTransfer = "setcolortransfer", SetBlackGeneration = "setblackgeneration", + SetUnderColorRemoval = "setundercolorremoval", SetColorRendering = "setcolorrendering", SetSmoothness = "setsmoothness", + SetStrokeAdjust = "setstrokeadjust", CurrentStrokeAdjust = "currentstrokeadjust", SetPageDevice = "setpagedevice", + CurrentPageDevice = "currentpagedevice", SetUserParams = "setuserparams", SetSystemParams = "setsystemparams", + SetObjectFormat = "setobjectformat", ShowPage = "showpage", CopyPage = "copypage", ErasePage = "erasepage", + CurrentScreen = "currentscreen", SetColorScreen = "setcolorscreen", CurrentHalftone = "currenthalftone", + CurrentTransfer = "currenttransfer", CurrentColorTransfer = "currentcolortransfer", + CurrentBlackGeneration = "currentblackgeneration", CurrentUnderColorRemoval = "currentundercolorremoval", + SetCacheLimit = "setcachelimit", UCache = "ucache", SetUCacheParams = "setucacheparams", FindEncoding = "findencoding", + // Coordinates. + Matrix = "matrix", IdentMatrix = "identmatrix", DefaultMatrix = "defaultmatrix", CurrentMatrix = "currentmatrix", + SetMatrix = "setmatrix", InitMatrix = "initmatrix", Concat = "concat", ConcatMatrix = "concatmatrix", + Translate = "translate", Scale = "scale", Rotate = "rotate", Transform = "transform", ITransform = "itransform", + DTransform = "dtransform", IDTransform = "idtransform", InvertMatrix = "invertmatrix", + // Paths and painting. + NewPath = "newpath", CurrentPoint = "currentpoint", MoveTo = "moveto", RMoveTo = "rmoveto", LineTo = "lineto", + RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct", + Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath", + PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip", + RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill", + RectStroke = "rectstroke", ShFill = "shfill", + // Images. + Image = "image", ImageMask = "imagemask", ColorImage = "colorimage", + // Files. + CurrentFile = "currentfile", Filter = "filter", ReadHexString = "readhexstring", ReadString = "readstring", + ReadLine = "readline", FlushFile = "flushfile", CloseFile = "closefile", File = "file", Eexec = "eexec", + // Fonts and type. + FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont", + CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource", + DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow", + WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow", + KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath", + SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth", +} diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs new file mode 100644 index 00000000..79b2c61f --- /dev/null +++ b/crates/eps/src/import/tests.rs @@ -0,0 +1,454 @@ +//! The PostScript reader: each operator family on a small program, files the EPS writer made, +//! the preview fallback and refused files. + +use std::sync::Arc; + +use vectorcraft_color::{Color, GradientKind, Paint}; +use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use crate::import::{Dsc, import}; +use crate::{EpsOptions, Level, Preview, Raster}; + +/// `body` as an EPS file with a 100 × 100 pt bounding box. +fn eps(body: &str) -> Vec { + format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes() +} + +fn read(body: &str) -> crate::Imported { + import(&eps(body)).unwrap() +} + +/// The objects of the document's layer. +fn objects(d: &Document) -> Vec> { + d.layers[0].children().unwrap().clone() +} + +/// Every object, down through groups, in order. +fn all(d: &Document) -> Vec> { + fn walk(n: &Arc, out: &mut Vec>) { + out.push(n.clone()); + for c in n.children().into_iter().flatten() { + walk(c, out); + } + } + let mut out = vec![]; + for n in objects(d) { + walk(&n, &mut out); + } + out +} + +fn fill(n: &Node) -> Option<&Paint> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => Some(&f.paint), + _ => None, + }) +} + +fn stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => Some(s), + _ => None, + }) +} + +fn bounds(n: &Node) -> Rect { + n.geometric_bounds().unwrap() +} + +fn near(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 0.05) +} + +#[test] +fn paths_fill_and_stroke_in_document_space() { + let r = read("newpath 10 20 moveto 60 20 lineto 60 70 lineto closepath 1 0 0 setrgbcolor fill"); + let n = &objects(&r.document)[0]; + // PostScript's y goes up from the bottom of the bounding box. + assert!(near(bounds(n), Rect::new(10.0, 30.0, 60.0, 80.0)), "{:?}", bounds(n)); + assert_eq!(fill(n), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Curves and arcs. + let r = read("0 0 moveto 0 50 50 50 50 0 curveto stroke 50 50 20 0 360 arc fill"); + let o = objects(&r.document); + assert_eq!(o.len(), 2); + assert!(stroke(&o[0]).is_some() && fill(&o[0]).is_none()); + assert!(near(bounds(&o[1]), Rect::new(30.0, 30.0, 70.0, 70.0)), "{:?}", bounds(&o[1])); + // Relative operators, rectangles. + let r = read("10 10 moveto 20 0 rlineto 0 20 rlineto closepath fill 5 5 10 10 rectfill 0 0 4 4 rectstroke"); + let o = objects(&r.document); + assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0))); + assert!(near(bounds(&o[1]), Rect::new(5.0, 85.0, 15.0, 95.0))); + assert!(stroke(&o[2]).is_some()); +} + +#[test] +fn colours_in_every_model() { + let r = read( + "0 0 10 10 rectfill 0.25 setgray 0 0 10 10 rectfill 0 1 0 0 setcmykcolor 0 0 10 10 rectfill 0 1 1 sethsbcolor 0 0 10 10 rectfill \ + [/Separation (Gold) /DeviceCMYK {dup 0 exch 0.5 mul 0} bind] setcolorspace 0.5 setcolor 0 0 10 10 rectfill \ + /DeviceRGB setcolorspace 0 0 1 setcolor 0 0 10 10 rectfill \ + [/Indexed /DeviceRGB 1 ] setcolorspace 1 setcolor 0 0 10 10 rectfill", + ); + let fills: Vec = objects(&r.document).iter().map(|n| fill(n).unwrap().clone()).collect(); + // The initial colour is black; grey is ink coverage in the document. + assert_eq!(fills[0], Paint::solid(Color::gray(1.0))); + assert_eq!(fills[1], Paint::solid(Color::gray(0.75))); + assert_eq!(fills[2], Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0))); + let [r0, g0, b0] = fills[3].color().unwrap().to_rgb(); + assert!(r0 > 0.99 && g0 < 0.01 && b0 < 0.01); + // A spot ink links to its spot swatch at the tint. + let Paint::Solid { swatch: Some(name), tint, .. } = &fills[4] else { panic!("{:?}", fills[4]) }; + assert_eq!((name.as_str(), *tint), ("Gold", 0.5)); + let gold = r.document.swatch("Gold").unwrap(); + assert!(gold.spot && gold.paint.color() == Some(Color::cmyk(1.0, 0.0, 0.5, 0.0)), "{gold:?}"); + assert_eq!(fills[5], Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(fills[6], Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + // Mostly CMYK art opens in CMYK. + let r = read("0 0 0 1 setcmykcolor 0 0 5 5 rectfill 1 0 0 0 setcmykcolor 0 0 5 5 rectfill"); + assert_eq!(r.document.color_mode, ColorMode::Cmyk); + assert_eq!(read("1 0 0 setrgbcolor 0 0 5 5 rectfill").document.color_mode, ColorMode::Rgb); +} + +#[test] +fn gsave_and_grestore_bring_the_state_back() { + let r = read("gsave 1 0 0 setrgbcolor 5 setlinewidth 50 50 translate 0 0 10 10 rectfill grestore 0 0 10 10 rectfill"); + let o = objects(&r.document); + assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + assert!(near(bounds(&o[0]), Rect::new(50.0, 40.0, 60.0, 50.0))); + assert_eq!(fill(&o[1]), Some(&Paint::solid(Color::gray(1.0)))); + assert!(near(bounds(&o[1]), Rect::new(0.0, 90.0, 10.0, 100.0))); + // save/restore too. + let o = objects(&read("save 0.5 setgray restore 0 0 1 1 rectfill").document); + assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::gray(1.0)))); +} + +#[test] +fn transforms_concat() { + let r = read("[2 0 0 2 10 10] concat 0 0 10 10 rectfill 90 rotate 0 0 5 5 rectfill"); + let o = objects(&r.document); + assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0)), "{:?}", bounds(&o[0])); + // Rotated a quarter turn about (10, 10), scaled 2: x goes up, y to the left. + assert!(near(bounds(&o[1]), Rect::new(0.0, 80.0, 10.0, 90.0)), "{:?}", bounds(&o[1])); + let r = read("10 20 translate 2 3 scale 0 0 moveto 10 10 lineto stroke"); + assert!(near(bounds(&objects(&r.document)[0]), Rect::new(10.0, 50.0, 30.0, 80.0))); + // Matrix operators: transform and itransform. + let r = read("2 2 scale 3 4 transform itransform translate 0 0 1 1 rectfill"); + assert!(near(bounds(&objects(&r.document)[0]), Rect::new(6.0, 90.0, 8.0, 92.0)), "{:?}", bounds(&objects(&r.document)[0])); +} + +#[test] +fn line_state_reaches_the_stroke() { + let r = read("2 setlinewidth 1 setlinecap 2 setlinejoin 4 setmiterlimit [3 1] 0.5 setdash 2 2 scale 0 0 moveto 10 0 lineto stroke"); + let n = &objects(&r.document)[0]; + let st = stroke(n).unwrap(); + // The width and dashes grow with the transform. + assert_eq!((st.width, st.cap, st.join, st.miter_limit), (4.0, LineCap::Round, LineJoin::Bevel, 4.0)); + let dash = st.dash.as_ref().unwrap(); + assert_eq!((dash.pattern.clone(), dash.offset), (vec![6.0, 2.0], 1.0)); + // A zero-width line is a hairline. + assert!(stroke(&objects(&read("0 setlinewidth 0 0 moveto 9 9 lineto stroke").document)[0]).unwrap().width > 0.0); +} + +#[test] +fn procedures_bind_def_loops_and_dictionaries() { + let r = read( + "/bd {bind def} bind def /sq {newpath moveto 10 0 rlineto 0 10 rlineto -10 0 rlineto closepath fill} bd \ + 0 20 60 {0 sq} for 3 {} repeat /n 0 def {n 1 add /n exch def n 2 ge {exit} if} loop \ + 5 dict begin /x 70 def x 70 sq end \ + << /a 1 /b 2 >> /b get 2 eq {0 0 sq} if \ + [1 2 3] {pop} forall (abc) length 3 eq {80 80 sq} if \ + /nothing where {pop} {80 0 sq} ifelse {nonsense} stopped {90 90 moveto 95 95 lineto stroke} if", + ); + let o = objects(&r.document); + // Four squares from the loop, one in the dictionary, one from get, one from length, one from + // where, the stroke after stopped. + assert_eq!(o.len(), 9, "{:?}", r.warnings); + assert!(near(bounds(&o[3]), Rect::new(60.0, 90.0, 70.0, 100.0))); + assert!(near(bounds(&o[4]), Rect::new(70.0, 20.0, 80.0, 30.0))); +} + +#[test] +fn a_fill_then_a_stroke_of_the_same_path_is_one_object() { + let r = read("10 10 moveto 50 50 lineto 90 10 lineto closepath gsave 1 0 0 setrgbcolor fill grestore 0 0 1 setrgbcolor stroke"); + let o = objects(&r.document); + assert_eq!(o.len(), 1); + assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(stroke(&o[0]).unwrap().paint, Paint::solid(Color::rgb(0.0, 0.0, 1.0))); +} + +#[test] +fn clips_become_clipping_groups() { + let r = read("gsave 0 0 50 50 rectclip 0 0 100 100 rectfill 10 10 5 5 rectfill grestore 60 60 5 5 rectfill"); + let o = objects(&r.document); + assert_eq!(o.len(), 2); + let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("{:?}", o[0].kind) }; + assert_eq!(children.len(), 3); + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(near(bounds(&children[0]), Rect::new(0.0, 50.0, 50.0, 100.0))); + // A clip around the whole page clips nothing. + let o = objects(&read("0 0 100 100 rectclip 1 1 5 5 rectfill").document); + assert!(matches!(o[0].kind, NodeKind::Path { .. })); + // eoclip of a path. + let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document); + assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. })); +} + +#[test] +fn shadings_become_gradients() { + let axial = + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>"; + let r = read(&format!("gsave 10 10 moveto 90 10 lineto 50 90 lineto closepath clip newpath {axial} shfill grestore")); + let o = objects(&r.document); + // The clip path filled with the gradient. + assert_eq!(o.len(), 1, "{o:?}"); + let Some(Paint::Gradient(g)) = fill(&o[0]) else { panic!("{:?}", o[0]) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + assert_eq!(g.gradient.stops.len(), 2); + assert_eq!(g.gradient.stops[1].color, Color::rgb(0.0, 0.0, 1.0)); + let geom = g.geom.unwrap(); + assert_eq!((geom.start, geom.end), (Point::new(0.0, 100.0), Point::new(100.0, 100.0))); + // Stitched functions keep their bounds as stops; radial shadings, and shading patterns. + let radial = "<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>"; + let r = read(&format!("<< /PatternType 2 /Shading {radial} >> matrix makepattern setpattern 0 0 100 100 rectfill")); + let o = objects(&r.document); + let Some(Paint::Gradient(g)) = fill(&o[0]) else { panic!() }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + let offsets: Vec = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, vec![0.0, 0.25, 1.0]); +} + +#[test] +fn images_and_image_masks() { + // 2 × 2 RGB from hex data in the file, through a procedure. + let r = read( + "10 10 translate 20 20 scale 2 2 8 [2 0 0 -2 0 2] {currentfile 6 string readhexstring pop} false 3 colorimage\nff000000ff000000ffffffff\n", + ); + let o = objects(&r.document); + let NodeKind::Image(im) = &o[0].kind else { panic!("{:?} {:?}", o, r.warnings) }; + assert_eq!((im.width, im.height), (2, 2)); + assert!(near(bounds(&o[0]), Rect::new(10.0, 70.0, 30.0, 90.0)), "{:?}", bounds(&o[0])); + let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(png.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(png.get_pixel(1, 1).0, [255, 255, 255, 255]); + // The dictionary form through ASCII85 and run-length filters, grey with a decode array. + let rl = crate::ps::ascii85(&[1, 0, 255, 128]); + let r = read(&format!( + "/src currentfile /ASCII85Decode filter /RunLengthDecode filter def /DeviceGray setcolorspace \ + << /ImageType 1 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [1 0] /ImageMatrix [2 0 0 1 0 0] /DataSource src >> image\n{rl}" + )); + let NodeKind::Image(im) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) }; + let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(0, 0).0, png.get_pixel(1, 0).0), ([255, 255, 255, 255], [0, 0, 0, 255])); + // An image mask paints the current colour where its bits say. + let r = read("1 0 0 setrgbcolor 8 1 true [8 0 0 1 0 0] imagemask"); + let NodeKind::Image(im) = &objects(&r.document)[0].kind else { panic!() }; + let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(0, 0).0, png.get_pixel(7, 0).0), ([255, 0, 0, 255], [255, 0, 0, 0])); +} + +#[test] +fn type_becomes_point_type_in_the_font_named() { + let r = read("/Helvetica-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show (!) show"); + let o = objects(&r.document); + assert_eq!(o.len(), 2); + let NodeKind::Text(t) = &o[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "Hi"); + let style = &t.runs[0].style; + assert_eq!((style.font_family.as_str(), style.font_style.as_str()), ("Helvetica", "Bold")); + assert!((style.size - 12.0).abs() < 1e-6); + // On the baseline at (10, 80) in the document, upright. + assert!((t.xf.translation().x - 10.0).abs() < 1e-6 && (t.xf.translation().y - 80.0).abs() < 1e-6, "{:?}", t.xf); + assert!((t.xf.as_coeffs()[0] - 1.0).abs() < 1e-6 && (t.xf.as_coeffs()[3] - 1.0).abs() < 1e-6, "{:?}", t.xf); + // The second show starts where the first ended. + let NodeKind::Text(t2) = &o[1].kind else { panic!() }; + assert!(t2.xf.translation().x > 15.0); + assert_eq!(crate::family_style("ABCDEF+TimesNewRomanPS-BoldItalicMT"), ("Times New Roman".into(), "Bold Italic".into())); + assert_eq!(crate::family_style("Times-Roman"), ("Times".into(), "Regular".into())); +} + +#[test] +fn the_writers_own_output_reads_back() { + use vectorcraft_doc::{Appearance, Dash}; + use vectorcraft_geom::shapes; + 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::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let mut red = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), look); + if let Some(AppearanceItem::Stroke(s)) = red.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }); + s.cap = LineCap::Round; + } + d.insert(Some(layer), 0, red).unwrap(); + let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(110.0, 50.0), end: Point::new(190.0, 50.0), aspect: 1.0, focal: None }); + let grad = Node::path( + d.alloc_id(), + shapes::ellipse(Rect::new(110.0, 10.0, 190.0, 90.0)), + Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), + ); + d.insert(Some(layer), 1, grad).unwrap(); + for level in [Level::Three, Level::Two] { + let r = d.artboards[0].rect; + let o = EpsOptions { region: r, origin: Point::new(r.x0, r.y1), preview: Preview::None, level, ..EpsOptions::default() }; + let bytes = crate::export(&d, &o, None, None).unwrap().bytes; + let back = import(&bytes).unwrap(); + assert!(back.warnings.is_empty(), "{level:?}: {:?}", back.warnings); + assert_eq!(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0)); + let objs = all(&back.document); + let rect = objs.iter().find(|n| fill(n).is_some_and(|p| p.color() == Some(Color::rgb(1.0, 0.0, 0.0)))).unwrap(); + assert!(near(bounds(rect), Rect::new(10.0, 10.0, 90.0, 60.0)), "{:?}", bounds(rect)); + let st = objs.iter().find_map(|n| stroke(n)).unwrap(); + assert_eq!((st.width, st.cap, st.dash.as_ref().unwrap().pattern.clone()), (4.0, LineCap::Round, vec![6.0, 3.0])); + // Level 3 writes the gradient as a shading (a gradient again); level 2 as bands. + let gradients = objs.iter().filter(|n| matches!(fill(n), Some(Paint::Gradient(_)))).count(); + assert_eq!(gradients, usize::from(level == Level::Three), "{level:?}"); + } +} + +#[test] +fn an_unreadable_program_falls_back_to_the_preview() { + let ps = eps("0 0 10 10 rectfill frobnicate"); + let tiff = crate::tiff::encode(&Raster { width: 4, height: 4, rgba: [0, 128, 0, 255].repeat(16) }, false, false).unwrap(); + let bytes = crate::dos_eps(&ps, &tiff).unwrap(); + let r = import(&bytes).unwrap(); + assert!(r.preview); + assert!(r.warnings[0].contains("frobnicate") && r.warnings[0].contains("preview"), "{:?}", r.warnings); + let o = objects(&r.document); + let NodeKind::Image(im) = &o[0].kind else { panic!() }; + assert_eq!((im.width, im.height), (4, 4)); + assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0))); + // Without a preview, what was drawn before the error stays, with a warning. + let r = import(&ps).unwrap(); + assert!(!r.preview && objects(&r.document).len() == 1); + assert!(r.warnings[0].contains("frobnicate"), "{:?}", r.warnings); +} + +#[test] +fn garbage_and_runaway_programs_are_refused() { + assert!(import(b"").is_err()); + assert!(import(b"hello world").is_err()); + assert!(import(&[0xC5, 0xD0, 0xD3, 0xC6, 0xff, 0xff]).is_err()); + let e = import(b"%!PS\nfoo bar").unwrap_err(); + assert!(e.contains("foo"), "{e}"); + for body in ["{} loop", "/f {f} def f", "0 1 1e300 {pop} for", "[ 0 1 200000 {} for ]", "1000000000 array", "(abc", "{ 1 2"] { + assert!(import(&eps(body)).is_err(), "{body}"); + } + // Damaged numbers draw nothing far away. + let r = read("1e300 1e300 moveto 1e301 0 lineto stroke 0 0 1 1 rectfill"); + assert_eq!(objects(&r.document).len(), 1); +} + +#[test] +fn the_bounding_box_comes_from_the_header_or_the_trailer() { + let d = Dsc::read(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 1 2 30 40\n%%HiResBoundingBox: 1.5 2.5 29.5 39.5\n%%EndComments\n"); + assert_eq!(d.bbox, Some([1.5, 2.5, 29.5, 39.5])); + let d = Dsc::read(b"%!PS-Adobe-3.0\n%%BoundingBox: (atend)\n%%Pages: 3\n%%EndComments\nshowpage\n%%Trailer\n%%BoundingBox: 0 0 10 20\n"); + assert_eq!((d.bbox, d.pages), (Some([0.0, 0.0, 10.0, 20.0]), Some(3))); + let r = import(b"%!PS-Adobe-3.0\n%%Pages: 2\n0 0 5 5 rectfill showpage 0 0 9 9 rectfill showpage\n").unwrap(); + // No bounding box: a letter page; only the first page. + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0)); + assert_eq!(objects(&r.document).len(), 1); + assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings); +} + +#[test] +fn embedded_font_programs_are_skipped() { + let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\ + 0123456789abcdef garbage that is encrypted\n0000000000000000000000000000000000000000000000000000000000000000\ncleartomark\n\ + /MyFont findfont 10 scalefont setfont 10 10 moveto (x) show"; + let r = read(body); + let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) }; + assert_eq!(t.runs[0].style.font_family, "My Font"); +} + +/// A document with what the writer writes in its own ways: gradients, type, an image with +/// transparent pixels, a clipping group, a spot colour, dashes. +fn rich_doc() -> Document { + use vectorcraft_color::{Gradient, GradientGeom, GradientPaint, Swatch}; + use vectorcraft_doc::{Appearance, CharStyle, ImageBlob, ImageObject, TextObject}; + use vectorcraft_geom::{Affine, shapes}; + let mut d = Document::new(300.0, 200.0); + let layer = d.layers[0].id; + let add = |d: &mut Document, n: Node| d.insert(Some(layer), usize::MAX, n).unwrap(); + let solid = |r, g, b| Paint::solid(Color::rgb(r, g, b)); + let n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), + Appearance::basic(solid(1.0, 0.0, 0.0), solid(0.0, 0.0, 1.0), 4.0), + ); + add(&mut d, n); + for (kind, r) in [(GradientKind::Linear, Rect::new(110.0, 10.0, 190.0, 90.0)), (GradientKind::Radial, Rect::new(200.0, 10.0, 290.0, 90.0))] { + let mut g = GradientPaint::new(Gradient { kind, ..Gradient::default() }); + g.geom = Some(GradientGeom::fit(kind, r, 0.0)); + let n = Node::path(d.alloc_id(), shapes::ellipse(r), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0)); + add(&mut d, n); + } + let style = CharStyle { size: 30.0, fill: solid(0.0, 0.5, 0.0), stroke: Paint::None, ..CharStyle::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 120.0), "Type", style)))); + add(&mut d, n); + let mut img = image::RgbaImage::new(8, 4); + for (x, _, p) in img.enumerate_pixels_mut() { + *p = image::Rgba(if x < 4 { [0, 0, 255, 255] } else { [255, 200, 0, 255] }); + } + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("pic".into(), ImageBlob::new("image/png", png)); + let im = ImageObject { + key: "pic".into(), + width: 8, + height: 4, + xf: Affine::new([10.0, 0.0, 0.0, 10.0, 110.0, 110.0]), + link: None, + placement: Default::default(), + }; + let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + add(&mut d, n); + d.swatches.push(Swatch { name: "Spot".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); + let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Spot".into()), tint: 1.0 }; + let mut clip = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(200.0, 110.0, 290.0, 190.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(190.0, 100.0, 300.0, 150.0)), Appearance::basic(spot, Paint::None, 0.0)); + inner.appearance.items.push(AppearanceItem::Stroke({ + let mut st = vectorcraft_doc::StrokeLayer::new(solid(0.0, 0.0, 0.0), 2.0); + st.dash = Some(vectorcraft_doc::Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }); + st + })); + let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inner)], clip: true }); + add(&mut d, g); + d +} + +#[test] +fn the_writers_output_reads_back_looking_the_same() { + let d = rich_doc(); + let r = d.artboards[0].rect; + let mut renderer = vectorcraft_render::Renderer::new(); + let want = renderer.render_region(&d, r, 1.0, true); + for level in [Level::Three, Level::Two] { + let o = EpsOptions { region: r, origin: Point::new(r.x0, r.y1), preview: Preview::None, level, ..EpsOptions::default() }; + let back = import(&crate::export(&d, &o, None, None).unwrap().bytes).unwrap(); + assert!(back.warnings.is_empty(), "{level:?}: {:?}", back.warnings); + assert!(back.document.swatch("Spot").is_some_and(|s| s.spot), "{level:?}"); + let got = renderer.render_region(&back.document, back.document.artboards[0].rect, 1.0, true); + assert_eq!((got.width, got.height), (want.width, want.height)); + // Pixels that differ visibly: anti-aliased edges, gradient steps at level 2. + let off = want.pixels.chunks(4).zip(got.pixels.chunks(4)).filter(|(a, b)| a.iter().zip(b.iter()).any(|(x, y)| x.abs_diff(*y) > 48)).count(); + assert!(off * 100 < want.pixels.len() / 4, "{level:?}: {off} pixels differ"); + } +} + +#[test] +fn device_settings_are_accepted_and_change_nothing() { + let r = read( + "currentscreen setscreen currenttransfer settransfer currentcolortransfer setcolortransfer currenthalftone sethalftone \ + currentblackgeneration setblackgeneration currentundercolorremoval setundercolorremoval 1 setflat true setstrokeadjust \ + << /PageSize [100 100] >> setpagedevice 1000 setcachelimit mark 1 2 setucacheparams ucache /StandardEncoding findencoding pop \ + 0 0 5 5 rectfill", + ); + assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs new file mode 100644 index 00000000..7869f41b --- /dev/null +++ b/crates/eps/src/import/text.rs @@ -0,0 +1,288 @@ +//! Fonts and type: fonts are found by name (their programs aren't run) and type becomes point +//! type in the app's font of that name, sized and turned as the font matrix and the current +//! transform say. + +use std::rc::Rc; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_text::TextLayout; + +use super::interp::{Interp, matrix_obj, matrix_of}; +use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; + +/// The font a program uses before it sets one. +const DEFAULT_FONT: &str = "Helvetica"; + +/// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`, +/// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset +/// prefix (`ABCDEF+`). +pub fn family_style(name: &str) -> (String, String) { + let name = match name.split_once('+') { + Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => name, + }; + let (family, style) = name.split_once('-').unwrap_or((name, "")); + let trim = |s: &str| -> String { + let s = ["PSMT", "MT", "PS"].iter().find_map(|suffix| s.strip_suffix(suffix)).unwrap_or(s); + // Words: a capital after a small letter starts one. + let mut out = String::with_capacity(s.len() + 4); + let mut prev_lower = false; + for ch in s.chars() { + if ch.is_ascii_uppercase() && prev_lower { + out.push(' '); + } + prev_lower = ch.is_ascii_lowercase(); + out.push(ch); + } + out + }; + let style = match trim(style).as_str() { + "" | "Roman" | "Regular" | "Book" | "Normal" => "Regular".to_string(), + s => s.to_string(), + }; + (trim(family), style) +} + +impl Interp<'_> { + pub fn text_op(&mut self, op: Op) -> Res { + use Op::*; + match op { + FindFont => { + let k = self.pop()?; + let font = self.find_font(&k)?; + self.push(Obj::Dict(font))?; + } + DefineFont => { + let font = self.pop_dict()?; + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "definefont".into()))?; + font.borrow_mut().entry(Key::name("FontName")).or_insert_with(|| k.obj()); + self.fonts.borrow_mut().insert(k, Obj::Dict(font.clone())); + self.push(Obj::Dict(font))?; + } + UndefineFont => { + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefinefont".into()))?; + self.fonts.borrow_mut().remove(&k); + } + ScaleFont | MakeFont => { + let m = if op == ScaleFont { + let s = self.pop_num()?; + Affine::scale(s) + } else { + self.pop_matrix()? + }; + let font = self.pop_dict()?; + self.push(Obj::Dict(transformed(&font, m)))?; + } + SetFont => { + let font = self.pop_dict()?; + self.g.font = Some(font); + } + SelectFont => { + let m = match self.pop()? { + Obj::Array { items, .. } => matrix_of(&items.borrow()).ok_or(PsError::Ps("typecheck", "selectfont".into()))?, + o => Affine::scale(o.as_num().ok_or(PsError::Ps("typecheck", "selectfont".into()))?), + }; + let k = self.pop()?; + let font = self.find_font(&k)?; + self.g.font = Some(transformed(&font, m)); + } + CurrentFont => { + let font = match self.g.font.clone() { + Some(f) => f, + None => self.find_font(&Obj::name(DEFAULT_FONT))?, + }; + self.push(Obj::Dict(font))?; + } + FindResource => { + let category = self.pop()?; + let k = self.pop()?; + if category.text().as_deref() == Some("Font") { + let font = self.find_font(&k)?; + return self.push(Obj::Dict(font)); + } + let key = k.key().ok_or(PsError::Ps("typecheck", "findresource".into()))?; + let v = self.category(&category)?.borrow().get(&key).cloned(); + self.push(v.ok_or_else(|| PsError::Ps("undefinedresource", k.text().map(|t| t.to_string()).unwrap_or_default()))?)?; + } + DefineResource => { + let category = self.pop()?; + let v = self.pop()?; + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?; + let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? }; + dict.borrow_mut().insert(k, v.clone()); + self.push(v)?; + } + ResourceStatus => { + let category = self.pop()?; + let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?; + let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? }; + let known = dict.borrow().contains_key(&k); + if known { + self.push(Obj::Int(1))?; + self.push(Obj::Int(0))?; + } + self.push(Obj::Bool(known))?; + } + Show => { + let s = self.pop_str()?.borrow().clone(); + self.show(&s)?; + } + AShow => { + let s = self.pop_str()?.borrow().clone(); + self.nums::<2>()?; + self.show(&s)?; + } + WidthShow => { + let s = self.pop_str()?.borrow().clone(); + self.pop()?; + self.nums::<2>()?; + self.show(&s)?; + } + AWidthShow => { + let s = self.pop_str()?.borrow().clone(); + self.nums::<2>()?; + self.pop()?; + self.nums::<2>()?; + self.show(&s)?; + } + XShow | YShow | XYShow => { + self.pop()?; + let s = self.pop_str()?.borrow().clone(); + self.show(&s)?; + } + KShow => { + let s = self.pop_str()?.borrow().clone(); + self.pop()?; + self.show(&s)?; + } + GlyphShow => { + let name = self.pop()?.text().unwrap_or_else(|| Rc::from("")); + let ch = glyph_char(&name); + self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?; + } + StringWidth => { + let s = self.pop_str()?.borrow().clone(); + let (_, _, v) = self.set_type(&s, Point::ZERO)?; + self.push_num(v.x)?; + self.push_num(v.y)?; + } + CharPath => { + self.pop_bool()?; + let s = self.pop_str()?.borrow().clone(); + self.char_path(&s)?; + } + SetCacheDevice => { + self.nums::<6>()?; + } + SetCharWidth => { + self.nums::<2>()?; + } + _ => return ps_err("undefined", op.name()), + } + Ok(()) + } + + /// The font named `k`: one the program defined, else a font of that name. + fn find_font(&mut self, k: &Obj) -> Res { + let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; + if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { + return Ok(d.clone()); + } + let mut d = Dict::new(); + d.insert(Key::name("FontName"), key.obj()); + d.insert(Key::name("FontType"), Obj::Int(1)); + d.insert(Key::name("FontMatrix"), matrix_obj(Affine::scale(0.001))); + d.insert(Key::name("FontBBox"), Obj::array(vec![Obj::Int(0), Obj::Int(-250), Obj::Int(1000), Obj::Int(1000)])); + d.insert(Key::name("Encoding"), Obj::array(vec![Obj::name(".notdef"); 256])); + let font = Rc::new(std::cell::RefCell::new(d)); + self.fonts.borrow_mut().insert(key, Obj::Dict(font.clone())); + Ok(font) + } + + /// The current font's name and its matrix for a font of one unit (user space). + fn font(&mut self) -> Res<(String, Affine)> { + let font = match self.g.font.clone() { + Some(f) => f, + None => self.find_font(&Obj::name(DEFAULT_FONT))?, + }; + let f = font.borrow(); + let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string()); + let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001)); + Ok((name, m * Affine::scale(1000.0))) + } + + /// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there, + /// its layout in text space, and how far it moves the current point (user space). + fn set_type(&mut self, s: &[u8], at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + let (name, f1) = self.font()?; + // Text space (y down, `size` points an em) onto the document. + let m = self.xf() * Affine::translate(at.to_vec2()) * f1; + let size = m.determinant().abs().sqrt(); + if !(size.is_finite() && size > 1e-3 && size < 1e5) { + return ps_err("undefinedresult", "a font size"); + } + let (font_family, font_style) = family_style(&name); + let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect(); + let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; + let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, &text, style); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size); + t.cached_bounds = Some(layout.bounds); + let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum(); + Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO)) + } + + /// `show`: point type at the current point, which moves past it. + fn show(&mut self, s: &[u8]) -> Res { + let at = self.user_point()?; + let (t, _, adv) = self.set_type(s, at)?; + if !self.g.paint.is_none() && !t.plain_text().trim().is_empty() { + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + } + self.g.cur = Some(self.xf() * (at + adv)); + Ok(()) + } + + /// `charpath`: the glyph outlines of `s` added to the path. + fn char_path(&mut self, s: &[u8]) -> Res { + let at = self.user_point()?; + let (t, layout, adv) = self.set_type(s, at)?; + for g in &layout.glyphs { + self.grow()?; + let mut o = g.outline.clone(); + o.apply_affine(t.xf); + self.g.path.extend(o); + } + self.g.cur = Some(self.xf() * (at + adv)); + Ok(()) + } +} + +/// A font scaled or transformed by `m` (`scalefont`, `makefont`). +fn transformed(font: &DictRef, m: Affine) -> DictRef { + let mut d = font.borrow().clone(); + let fm = d.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001)); + d.insert(Key::name("FontMatrix"), matrix_obj(m * fm)); + Rc::new(std::cell::RefCell::new(d)) +} + +/// The character a glyph name stands for (`A`, `space`, `uni20AC`), else a bullet. +fn glyph_char(name: &str) -> char { + let mut chars = name.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + return c; + } + if let Some(hex) = name.strip_prefix("uni").or_else(|| name.strip_prefix('u')) + && let Some(c) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) + { + return c; + } + match name { + "space" => ' ', + _ => '\u{2022}', + } +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index fdf9b21e..715abd2d 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -18,14 +18,22 @@ //! native document, both in comments that PostScript interpreters skip ([`native`], //! [`thumbnail`] read them back). //! +//! [`import`] reads EPS and PostScript files back: the native document when the file carries one +//! is [`native`]'s job; other files are run through a small PostScript interpreter, or come in as +//! their TIFF preview. +//! //! - `ps`: numbers, strings, paths and the data encodings (ASCII85, run-length, Flate). //! - `scene`: the document walk that writes the page. //! - `tiff`: the TIFF preview. +//! - `import`: the PostScript interpreter. +mod import; mod ps; mod scene; mod tiff; +pub use import::{Imported, family_style, import}; + use vectorcraft_doc::Document; use vectorcraft_geom::{Point, Rect}; @@ -365,6 +373,12 @@ pub fn native(bytes: &[u8]) -> Option> { ps::inflate(&read_data(ps, NATIVE)?, MAX_NATIVE) } +/// Does the EPS file carry a native document (readable or not)? +pub fn has_native(bytes: &[u8]) -> bool { + let marker = format!("{BEGIN_DATA}{NATIVE}"); + sections(bytes).is_some_and(|(ps, _)| ps.windows(marker.len()).any(|w| w == marker.as_bytes())) +} + /// The PNG thumbnail an EPS file written by [`export`] carries, if any. pub fn thumbnail(bytes: &[u8]) -> Option> { read_data(sections(bytes)?.0, THUMBNAIL) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index ded12e5b..482f2293 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -13,6 +13,7 @@ mod wmf; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::clipnest::{Clip, Drawn, nest}; use vectorcraft_doc::{ Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, @@ -116,15 +117,6 @@ pub(crate) enum Obj { Other, } -/// A clip: a region in document space and its rule. Clips nest; each has an id so what one clips -/// can be grouped. -#[derive(Debug)] -struct Clip { - id: u32, - path: BezPath, - rule: FillRule, -} - /// The state records change, saved and restored as a whole. #[derive(Clone, Debug)] struct Dc { @@ -860,7 +852,7 @@ impl Player { if out.is_empty() && self.emf_plus { self.warnings.push("the picture is drawn with EMF+ records, which aren't read yet: nothing was imported".into()); } - let children = nest(&mut self.doc, out, 0); + let children = nest(&mut self.doc, out); let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); if let Some(c) = layer.children_mut() { *c = children; @@ -889,45 +881,6 @@ fn crop(img: Rgba, src: Rect) -> Rgba { Rgba { width: x1 - x0, height: y1 - y0, pixels } } -type Drawn = Vec<(Vec>, Node)>; - -/// The nodes of `items`, those under clips `depth` and deeper grouped into clipping groups, in -/// order. -fn nest(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { - let mut out: Vec> = vec![]; - let mut group: Drawn = vec![]; - let mut clip: Option> = None; - for (chain, node) in items { - let here = chain.get(depth).cloned(); - if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) { - if let Some(c) = clip.take() { - out.push(clip_group(doc, &c, std::mem::take(&mut group), depth)); - } - clip = here.clone(); - } - match here { - Some(_) => group.push((chain, node)), - None => out.push(Arc::new(node)), - } - } - if let Some(c) = clip { - out.push(clip_group(doc, &c, group, depth)); - } - out -} - -/// A clipping group of `items`, clipped by `c` (their clip at `depth`). -fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc { - let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default()); - if let NodeKind::Path { clipping, rule, .. } = &mut path.kind { - *clipping = true; - *rule = c.rule; - } - let mut children = vec![Arc::new(path)]; - children.extend(nest(doc, items, depth + 1)); - Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true })) -} - /// Read a metafile. pub(crate) fn import(bytes: &[u8]) -> Result { match crate::sniff(bytes) { diff --git a/docs/mcp.md b/docs/mcp.md index dbb65b3f..5045b53a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -240,8 +240,8 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}} ``` -PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and -say so, as does an `.ai` whose PDF part is only a placeholder page. +PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS +reader (see EPS and PostScript import); an `.ai` whose PDF part is only a placeholder page says it can't be opened. What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art; the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout, @@ -1521,3 +1521,26 @@ unless `replace`). Saved presets live with the preferences. To print with a pres ```json {"name":"run_command","arguments":{"command":"print.presets.save","params":{"name":"Posters","settings":{"scaling":"tileImageable","overlap":18,"marks":{"trim":true}}}}} ``` + +## EPS and PostScript import + +`document.open` and `file.place` read `.eps` files, and PostScript by any name (an `.ai` saved without PDF +compatibility opens as artwork; an `.ait` one as a new untitled document). An EPS file VectorCraft wrote restores the +document it carries (`restored: true`, as Preserve Editing does for PDF and SVG); when that document can't be read the +PostScript is read instead and the first warning says why. + +Other files are run through a small PostScript interpreter (Level 3, first page only): paths, fills and strokes with +their width, caps, joins, miter limit and dashes (a fill and a stroke of the same path become one object), grey, RGB, +CMYK, indexed and spot colours (a Separation ink becomes a spot swatch, painted at its tint), clips (clipping groups), +`gsave`/`grestore`, `save`/`restore`, transforms, procedures with `bind def`, loops and dictionaries, axial and radial +shadings and shading patterns (gradients), images and image masks (data in the file through ASCII85, hex, run-length, +Flate, LZW or DCT filters, or from procedures), and type as point type in the font the file names (embedded font +programs are skipped). The artboard is the `%%HiResBoundingBox` (else `%%BoundingBox`; a letter page without one). +A program the interpreter can't run (an operator it doesn't know, an error, a runaway loop) or that draws nothing comes +in as its TIFF preview with a warning; without a preview, the art drawn up to the error is kept with a warning, and a +file with none is refused with a message saying why. + +```json +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/logo.eps"}}} +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/logo.eps","at":[300,300]}}} +``` From 0428e56342bbfa1426810ad4f0794462070cbffd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 16:42:51 +0700 Subject: [PATCH 2/2] 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 --- crates/engine/src/cmd/fileio/dxf.rs | 2 +- crates/engine/src/cmd/fileio/eps.rs | 5 +- crates/engine/src/cmd/print.rs | 84 +++++++++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_printps.rs | 96 +++++++++++++++ crates/eps/src/lib.rs | 36 +++--- crates/eps/src/print/mod.rs | 164 ++++++++++++++++++++++++++ crates/eps/src/print/tests.rs | 85 ++++++++++++++ crates/eps/src/scene.rs | 10 ++ crates/pdf/src/print/mod.rs | 175 ++++++++++++++++++++-------- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/print.rs | 25 ++-- crates/ui-egui/src/tests_printps.rs | 65 +++++++++++ docs/mcp.md | 12 ++ 15 files changed, 683 insertions(+), 82 deletions(-) create mode 100644 crates/engine/src/tests_printps.rs create mode 100644 crates/eps/src/print/mod.rs create mode 100644 crates/eps/src/print/tests.rs create mode 100644 crates/ui-egui/src/tests_printps.rs diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index f8d84007..be585f6a 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -126,7 +126,7 @@ struct Params { } /// A string or number param as text (`2018` and `"2018"` alike). -fn text(v: &Value) -> String { +pub(crate) fn text(v: &Value) -> String { match v { Value::String(s) => s.clone(), other => other.to_string(), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a732a71c..cac1500e 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -152,10 +152,7 @@ fn parse(p: &Value) -> Result { let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?; let mut flatten = flattener(&q, &[])?; flatten.preserve_overprints &= overprint == Overprint::Preserve; - let level = q.level.as_ref().map(|v| match v { - Value::String(s) => s.clone(), - other => other.to_string(), - }); + let level = q.level.as_ref().map(super::dxf::text); let eps = EpsOptions { level: choice("level", level, Level::from_id, d.level, "2 or 3")?, preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 3fd4ba06..dbebd0eb 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,13 +1,16 @@ -//! File → Print as a print-ready PDF (`file.print`), what it would print (`print.preview`) and the -//! print settings saved with the document (`print.setup`, [`vectorcraft_doc::Document::print_setup`]). +//! File → Print as a print-ready PDF or a PostScript file (`file.print`), what it would print +//! (`print.preview`) and the print settings saved with the document (`print.setup`, +//! [`vectorcraft_doc::Document::print_setup`]). use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_doc::Document; +use vectorcraft_eps::{Level, PrintJob, PrintPage}; use vectorcraft_pdf::{PrintOptions, PrintSettings}; use super::fileio::pdf::{merge, pdf_error}; -use super::fileio::write_or_return; +use super::fileio::{extension, write_or_return}; +use super::flatten::{FlattenOptions, flatten_document}; use super::*; const SETUP: &str = "print.setup"; @@ -37,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{settings?: {…print.setup settings} (over the document's), path?} print the document as a print-ready PDF, one page per sheet of paper (per tile, per ink, per copy) → {pages, path, bytes, warnings}; no path → {dataBase64, bytes, pages, warnings}. Raster effects print at the document's raster effects resolution; halftone screens and flatness are left to the output device", + "{settings?: {…print.setup settings} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -93,13 +96,76 @@ fn preview(s: &mut Session, p: &Value) -> Result { Ok(v) } +const PRINT: &str = "file.print"; + +/// Whether `file.print` writes PostScript: `format`, else a `.ps` path. +fn to_postscript(p: &Value) -> Result { + match str_param(p, "format") { + None => Ok(str_param(p, "path").is_some_and(|path| extension(path) == "ps")), + Some("postscript") => Ok(true), + Some("pdf") => Ok(false), + Some(f) => Err(bad(PRINT, format!("format `{f}`: pdf or postscript"))), + } +} + fn print(s: &mut Session, p: &Value) -> Result { - const C: &str = "file.print"; + let postscript = to_postscript(p)?; + let flattener = if postscript { + let preset = str_param(p, "flattenerPreset").unwrap_or("medium"); + Some( + FlattenOptions::find_preset(preset, &s.prefs.flattener_presets) + .ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?, + ) + } else { + None + }; let doc = &s.doc()?.doc; - let set = settings(C, doc, p)?; + let set = settings(PRINT, doc, p)?; // Raster effects print as images at the document's raster effects resolution. let flat = super::rasterfx::flatten_raster_effects(doc); - let r = - vectorcraft_pdf::print(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(C, e))?; - write_or_return(str_param(p, "path"), &r.bytes, json!({ "pages": r.pages, "warnings": r.warnings })) + let doc = flat.as_ref().unwrap_or(doc); + let path = str_param(p, "path"); + let Some(flattener) = flattener else { + let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?; + return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); + }; + let level = match p.get("level").filter(|v| !v.is_null()) { + Some(v) => { + let t = super::fileio::dxf::text(v); + Level::from_id(&t).ok_or_else(|| bad(PRINT, format!("level `{t}`: 2 or 3")))? + } + None => Level::default(), + }; + // PostScript has no transparency. + let flat = flatten_document(doc, &flattener)?; + let mut warnings = vec![]; + if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) { + warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); + } + let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); + let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) + .map_err(|e| pdf_error(PRINT, e))?; + let pages: Vec = plan + .sheets + .iter() + .map(|sh| PrintPage { + doc: sh.plate.and_then(|i| plan.plates.get(i)).unwrap_or(&plan.doc), + size: sh.size, + view: sh.view, + window: sh.window, + place: sh.place, + area: sh.area, + marks: sh.marks.as_ref(), + negative: sh.negative, + ink: sh.ink.as_ref().map(|i| (i.name.as_str(), i.frequency, i.angle)), + }) + .collect(); + let job = PrintJob { level, title: plan.title.clone(), created: plan.created, flatness }; + let out = vectorcraft_eps::print(&pages, &plan.order, &job).map_err(|e| bad(PRINT, e))?; + for w in plan.warnings.iter().chain(&out.warnings) { + if !warnings.contains(w) { + warnings.push(w.clone()); + } + } + write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8eaebb10..54f1592a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1219,6 +1219,8 @@ mod tests_printpresets; #[cfg(test)] mod tests_printpreview; #[cfg(test)] +mod tests_printps; +#[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_rastersettings; diff --git a/crates/engine/src/tests_printps.rs b/crates/engine/src/tests_printps.rs new file mode 100644 index 00000000..4eeebc7b --- /dev/null +++ b/crates/engine/src/tests_printps.rs @@ -0,0 +1,96 @@ +//! File → Print to a PostScript file: `file.print {format: "postscript"}` writes the job the PDF +//! print lays out as DSC-conforming PostScript. + +use serde_json::{Value, json}; + +use super::*; + +/// A document of `artboards` 300 × 200 artboards with a rectangle on the first. +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s +} + +/// `file.print {format: "postscript", …p}` → (the PostScript, the answer). +fn print_ps(s: &mut Session, mut p: Value) -> (String, Value) { + p["format"] = json!("postscript"); + let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), v) +} + +/// The values of DSC comment `key` (`%%Key:`), in order. +fn dsc(ps: &str, key: &str) -> Vec { + let prefix = format!("%%{key}:"); + ps.lines().filter_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()).collect() +} + +#[test] +fn pages_equals_the_page_count() { + let mut s = session(3); + let (ps, v) = print_ps(&mut s, json!({})); + assert_eq!((v["pages"].clone(), v["format"].clone()), (json!(3), json!("postscript"))); + assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n")); + assert_eq!(dsc(&ps, "Pages"), ["3"]); + assert_eq!(dsc(&ps, "Page").len(), 3); + assert_eq!(dsc(&ps, "LanguageLevel"), ["3"]); + // Copies, a range of artboards, and the level. + let (ps, v) = print_ps(&mut s, json!({"level": 2, "settings": {"copies": 2, "artboards": "range", "range": "1-2"}})); + assert_eq!((v["pages"].clone(), dsc(&ps, "Pages")), (json!(4), vec!["4".to_string()])); + assert_eq!(dsc(&ps, "Page").len(), 4); + assert_eq!(dsc(&ps, "LanguageLevel"), ["2"]); + // Letter paper, turned to the wide artboards. + assert_eq!(dsc(&ps, "PageBoundingBox")[0], "0 0 792 612"); +} + +#[test] +fn marks_and_separations_are_written() { + let mut s = session(1); + let (ps, _) = print_ps(&mut s, json!({"settings": {"marks": {"trim": true, "registration": true}}})); + // Marks print in Registration: on every ink. + assert!(ps.contains("/All /DeviceCMYK"), "{ps}"); + let (none, _) = print_ps(&mut s, json!({})); + assert!(!none.contains("/All /DeviceCMYK")); + // Separations: a page per ink with its screen. + let (ps, v) = + print_ps(&mut s, json!({"settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "frequency": 120, "angle": 30}]}}})); + assert_eq!(v["pages"], 4); + assert_eq!(dsc(&ps, "PlateColor"), ["Cyan", "Magenta", "Yellow", "Black"]); + assert!(ps.contains("120 30 {dup mul exch dup mul add 1 exch sub} setscreen")); + // PostScript writes the screens, so no warning says they are left to the device. + assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("screens")), "{v}"); + // Negatives and a fixed flatness. + let (ps, _) = print_ps(&mut s, json!({"settings": {"output": {"image": "negative"}, "graphics": {"autoFlatness": false, "flatness": 4}}})); + assert!(ps.contains("{1 exch sub} settransfer") && ps.contains("4 setflat")); +} + +#[test] +fn a_ps_path_prints_postscript_and_pdf_stays_the_default() { + let mut s = session(1); + let v = s.execute("file.print", &json!({})).unwrap(); + assert_eq!(v["format"], "pdf"); + let path = std::env::temp_dir().join(format!("vc-printps-{}.ps", std::process::id())); + let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(v["format"], "postscript"); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0")); + let _ = std::fs::remove_file(&path); + // The file reads back: the rectangle on the first page. + let back = vectorcraft_eps::import(&bytes).unwrap(); + assert!(!back.document.layers[0].children().unwrap().is_empty()); + for bad in [json!({"format": "ps2"}), json!({"format": "postscript", "level": 1}), json!({"format": "postscript", "flattenerPreset": "nope"})] { + assert!(matches!(s.execute("file.print", &bad), Err(EngineError::BadParams { .. })), "{bad}"); + } +} + +#[test] +fn transparency_is_flattened_for_postscript() { + let mut s = session(1); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let (ps, v) = print_ps(&mut s, json!({})); + assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened")), "{v}"); + assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("opaque: EPS")), "{v}"); + assert_eq!(dsc(&ps, "Pages"), ["1"]); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 715abd2d..e8fc806f 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -26,13 +26,16 @@ //! - `scene`: the document walk that writes the page. //! - `tiff`: the TIFF preview. //! - `import`: the PostScript interpreter. +//! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`. mod import; +mod print; mod ps; mod scene; mod tiff; pub use import::{Imported, family_style, import}; +pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; use vectorcraft_geom::{Point, Rect}; @@ -244,6 +247,25 @@ const THUMBNAIL: &str = "thumbnail"; /// Largest native document [`native`] inflates (bytes). const MAX_NATIVE: u64 = 1 << 30; +/// The DSC comments every file of ours has after its version line: creator, title, date, +/// language level, data, and the spot colours `custom` with their CMYK equivalents. +pub(crate) fn comments(s: &mut String, title: &str, created: Option, level: Level, custom: &[(String, [f32; 4])]) { + use std::fmt::Write; + let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%Title: {}", ps::string(title)); + if let Some(t) = created { + let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t); + let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)"); + } + let _ = writeln!(s, "%%LanguageLevel: {}", level.id()); + s.push_str("%%DocumentData: Clean7Bit\n"); + for (i, (name, cmyk)) in custom.iter().enumerate() { + let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name)); + let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); + let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name)); + } +} + /// The PostScript program: header comments, thumbnail, prolog, setup, the page and the trailer /// with the native document. fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4])], thumb: Option<&[u8]>) -> String { @@ -252,21 +274,9 @@ fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4] let bb = bounding_box(o.region, o.origin); let hi = hires_box(o.region, o.origin); s.push_str("%!PS-Adobe-3.0 EPSF-3.0\n"); // brand-ok: the DSC version line every EPS file starts with - let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); - let _ = writeln!(s, "%%Title: {}", ps::string(&o.title)); - if let Some(t) = o.created { - let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t); - let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)"); - } + comments(&mut s, &o.title, o.created, o.level, custom); let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", bb[0], bb[1], bb[2], bb[3]); let _ = writeln!(s, "%%HiResBoundingBox: {} {} {} {}", ps::num(hi[0]), ps::num(hi[1]), ps::num(hi[2]), ps::num(hi[3])); - let _ = writeln!(s, "%%LanguageLevel: {}", o.level.id()); - s.push_str("%%DocumentData: Clean7Bit\n"); - for (i, (name, cmyk)) in custom.iter().enumerate() { - let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name)); - let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); - let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name)); - } s.push_str("%%Pages: 1\n%%EndComments\n"); if let Some(png) = thumb { data_section(&mut s, THUMBNAIL, png); diff --git a/crates/eps/src/print/mod.rs b/crates/eps/src/print/mod.rs new file mode 100644 index 00000000..6fe7c657 --- /dev/null +++ b/crates/eps/src/print/mod.rs @@ -0,0 +1,164 @@ +//! Print to a PostScript file: the sheets a print job laid out ([`PrintPage`]), each drawn by the +//! document walk the EPS writer uses, in one DSC-conforming file: a page per sheet with its paper +//! size, the ink and halftone screen of a separation, the flatness, and film negatives. + +use std::fmt::Write; + +use kurbo::Shape; +use vectorcraft_doc::{Document, Node}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::scene::{Page, Scene}; +use crate::{EpsOptions, EpsOutput, Level, comments, ps}; + +/// One sheet of a print job (see the PDF writer's print plan for how the spaces relate). +#[derive(Clone, Debug)] +pub struct PrintPage<'a> { + /// The document the sheet draws: the job's, or a separation's plate. + pub doc: &'a Document, + /// The page size in points. + pub size: (f64, f64), + /// Drawing space → page space (y down from the top-left corner). + pub view: Affine, + /// The part of the drawing space that shows (a tile): art and marks are clipped to it. + pub window: Option, + /// Document space → drawing space. + pub place: Affine, + /// The art drawn, in document space: what lies outside it is left out. + pub area: Rect, + /// Printer's marks, in drawing space. + pub marks: Option<&'a Node>, + /// A film negative: paper black, ink clear. + pub negative: bool, + /// A separation's ink: its name, screen ruling (lpi) and angle (degrees). + pub ink: Option<(&'a str, f64, f64)>, +} + +/// The settings of a whole job. +#[derive(Clone, Debug, Default)] +pub struct PrintJob { + pub level: Level, + /// `%%Title`. + pub title: String, + /// `%%CreationDate` as Unix seconds (UTC). + pub created: Option, + /// The flatness curves are drawn with (device pixels); `None`: the device's own. + pub flatness: Option, +} + +/// Every number of `a` finite? +fn finite(a: Affine) -> bool { + a.as_coeffs().iter().all(|v| v.is_finite()) +} + +/// The program that draws sheet `p` (its page setup and content, without the DSC page comment), +/// and what its walks found. +fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { + let (w, h) = p.size; + let r = p.area; + if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { + return Err(format!("page size {w} × {h}")); + } + if !finite(p.view) || !finite(p.place) { + return Err("a page's placement is not finite".into()); + } + let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() }; + let art_opts = opts(r); + let art = Scene::new(p.doc, &art_opts).run(); + // The marks' area: the drawing space the page shows (within the tile). + let paper = Rect::new(0.0, 0.0, w, h); + let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper }; + let shown = p.window.map_or(shown, |t| t.intersect(shown)); + let marks_opts = opts(shown); + let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m)); + let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024); + let (wi, hi) = (w.ceil() as i64, h.ceil() as i64); + let _ = writeln!(s, "%%PageBoundingBox: 0 0 {wi} {hi}"); + if let Some((name, _, _)) = p.ink { + let _ = writeln!(s, "%%PlateColor: {name}"); + } + s.push_str("%%BeginPageSetup\n"); + let _ = writeln!(s, "/setpagedevice where {{pop << /PageSize [{} {}] >> setpagedevice}} if", ps::num(w), ps::num(h)); + s.push_str("%%EndPageSetup\nVCdict begin\nq\n"); + if p.negative { + // Ink becomes clear and paper black: the inverted transfer paints the paper's white black. + let _ = writeln!(s, "{{1 exch sub}} settransfer 1 g 0 0 {} {} rectfill", ps::num(w), ps::num(h)); + } + if let Some((_, lpi, angle)) = p.ink { + // A round dot. + let _ = writeln!(s, "{} {} {{dup mul exch dup mul add 1 exch sub}} setscreen", ps::num(lpi), ps::num(angle)); + } + if let Some(f) = job.flatness { + let _ = writeln!(s, "{} setflat", ps::num(f)); + } + // Page space (y down) onto the paper (y up), then the drawing space. + let _ = writeln!(s, "[1 0 0 -1 0 {}] cm", ps::num(h)); + let _ = writeln!(s, "{} cm", ps::matrix(p.view)); + if let Some(t) = p.window { + ps::push_path(&mut s, &t.to_path(0.1)); + s.push_str("W\n"); + } + s.push_str("q\n"); + let _ = writeln!(s, "{} cm", ps::matrix(p.place)); + ps::push_path(&mut s, &r.to_path(0.1)); + s.push_str("W\n"); + s.push_str(&art.setup); + s.push_str(&art.body); + s.push_str("Q\n"); + let mut found = vec![art]; + if let Some(m) = marks { + // The marks' spot colour spaces are defined after the art is drawn: names may be reused. + s.push_str(&m.setup); + s.push_str(&m.body); + found.push(m); + } + s.push_str("Q\nend\nshowpage\n%%PageTrailer\n"); + Ok((s, found)) +} + +/// Write the sheets `pages` in `order` (indices into `pages`; copies repeat them) as one +/// PostScript file of `order.len()` pages. Each sheet is drawn once and repeated for its copies. +pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result { + if order.is_empty() { + return Err("nothing to print".into()); + } + let mut drawn = Vec::with_capacity(pages.len()); + let mut custom: Vec<(String, [f32; 4])> = vec![]; + let mut warnings: Vec = vec![]; + for p in pages { + let (s, found) = sheet(p, job)?; + for page in found { + for c in page.custom { + if !custom.iter().any(|(n, _)| *n == c.0) { + custom.push(c); + } + } + for w in page.warnings { + if !warnings.contains(&w) { + warnings.push(w); + } + } + } + drawn.push(s); + } + let (w, h) = pages.iter().fold((0.0f64, 0.0f64), |(w, h), p| (w.max(p.size.0), h.max(p.size.1))); + let size: usize = order.iter().filter_map(|i| drawn.get(*i)).map(String::len).sum(); + let mut s = String::with_capacity(size + 4096); + s.push_str("%!PS-Adobe-3.0\n"); // brand-ok: the DSC version line every conforming file starts with + comments(&mut s, &job.title, job.created, job.level, &custom); + let _ = writeln!(s, "%%BoundingBox: 0 0 {} {}", w.ceil() as i64, h.ceil() as i64); + let _ = writeln!(s, "%%Pages: {}\n%%PageOrder: Ascend\n%%EndComments", order.len()); + s.push_str("%%BeginProlog\n"); + s.push_str(ps::PROLOG); + s.push_str("%%EndProlog\n%%BeginSetup\n%%EndSetup\n"); + for (n, i) in order.iter().enumerate() { + let page = drawn.get(*i).ok_or("a page out of range")?; + let _ = writeln!(s, "%%Page: {0} {0}", n + 1); + s.push_str(page); + } + s.push_str("%%Trailer\n%%EOF\n"); + Ok(EpsOutput { bytes: s.into_bytes(), warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/print/tests.rs b/crates/eps/src/print/tests.rs new file mode 100644 index 00000000..171bb770 --- /dev/null +++ b/crates/eps/src/print/tests.rs @@ -0,0 +1,85 @@ +//! PostScript print jobs: DSC page comments, the setup of each sheet, and a file the PostScript +//! reader reads back. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use super::*; + +/// A 200 × 100 pt document with a red rectangle. +fn doc() -> Document { + let mut d = Document::new(200.0, 100.0); + let layer = d.layers[0].id; + let look = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 60.0)), look); + d.insert(Some(layer), 0, n).unwrap(); + d +} + +/// The document on a letter page, 36 pt in from its top-left corner. +fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> { + PrintPage { + doc: d, + size: (612.0, 792.0), + view: Affine::IDENTITY, + window: None, + place: Affine::translate((36.0, 36.0)), + area: d.artboards[0].rect, + marks, + negative: false, + ink: None, + } +} + +fn text(out: &EpsOutput) -> String { + String::from_utf8(out.bytes.clone()).unwrap() +} + +#[test] +fn pages_follow_dsc_and_repeat_for_copies() { + let d = doc(); + let mark = + Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25)); + let pages = [page(&d, None), PrintPage { size: (792.0, 612.0), ..page(&d, Some(&mark)) }]; + let job = PrintJob { title: "Job".into(), created: Some(0), ..PrintJob::default() }; + let out = print(&pages, &[0, 1, 0, 1], &job).unwrap(); + let ps = text(&out); + assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n")); + let dsc = |key: &str| ps.lines().filter_map(|l| l.strip_prefix(key)).map(str::trim).collect::>(); + assert_eq!(dsc("%%Pages:"), ["4"]); + assert_eq!(dsc("%%Page:"), ["1 1", "2 2", "3 3", "4 4"]); + assert_eq!(dsc("%%PageBoundingBox:"), ["0 0 612 792", "0 0 792 612", "0 0 612 792", "0 0 792 612"]); + assert_eq!(dsc("%%BoundingBox:"), ["0 0 792 792"]); + assert_eq!(dsc("%%LanguageLevel:"), ["3"]); + assert_eq!(ps.matches("showpage").count(), 4); + assert!(ps.contains("<< /PageSize [792 612] >> setpagedevice")); + // The marks are drawn in Registration on the pages that have them. + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 2, "{ps}"); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + // The PostScript reader reads the first page back: the rectangle 36 pt in, clipped to the + // artboard. + let back = crate::import(&out.bytes).unwrap(); + let art = back.document.layers[0].children().unwrap().clone(); + let clipped = art[0].children().unwrap(); + assert_eq!(clipped[0].geometric_bounds().unwrap(), Rect::new(36.0, 36.0, 236.0, 136.0)); + assert_eq!(clipped[1].geometric_bounds().unwrap(), Rect::new(56.0, 56.0, 156.0, 96.0)); + assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings); +} + +#[test] +fn separations_screens_flatness_negatives_and_tiles() { + let d = doc(); + let sheet = PrintPage { negative: true, ink: Some(("Cyan", 85.0, 15.0)), window: Some(Rect::new(0.0, 0.0, 100.0, 100.0)), ..page(&d, None) }; + let job = PrintJob { level: Level::Two, flatness: Some(3.0), ..PrintJob::default() }; + let ps = text(&print(&[sheet], &[0], &job).unwrap()); + assert!(ps.contains("%%PlateColor: Cyan\n")); + assert!(ps.contains("85 15 {dup mul exch dup mul add 1 exch sub} setscreen")); + assert!(ps.contains("{1 exch sub} settransfer 1 g 0 0 612 792 rectfill")); + assert!(ps.contains("3 setflat")); + assert!(ps.contains("%%LanguageLevel: 2")); + // The tile clips the page. + assert!(ps.contains("0 0 m\n100 0 l\n100 100 l\n0 100 l\n"), "{ps}"); + assert!(print(&[], &[], &job).is_err()); + assert!(print(&[PrintPage { size: (f64::NAN, 1.0), ..page(&d, None) }], &[0], &job).is_err()); +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 136c9ec1..bef20136 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -132,6 +132,16 @@ impl<'a> Scene<'a> { for l in &doc.layers { self.node(l, false); } + self.page() + } + + /// Write one object (art outside the document: a print job's marks) instead of the layers. + pub fn run_node(mut self, n: &Node) -> Page { + self.node(n, false); + self.page() + } + + fn page(self) -> Page { let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect(); Page { setup: self.setup, body: self.body, custom, warnings: self.warnings } } diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 1655e884..3e0d1a97 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -15,7 +15,8 @@ //! colours keep their numbers. //! //! Halftone screens and flatness are left to the output device (warnings say so). [`preview`] -//! lists the pages, tiles and inks without writing the file. +//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other +//! writers (PostScript), which can write screens and flatness themselves. mod layout; mod plates; @@ -24,6 +25,7 @@ mod settings; use std::borrow::Cow; use std::sync::Arc; +use kurbo::{Affine, Rect}; use serde::Serialize; use vectorcraft_doc::marks::PrinterMarks; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; @@ -51,6 +53,9 @@ pub struct PrintOptions { pub created: Option, /// Content streams uncompressed (readable operators, for tests and debugging). pub uncompressed: bool, + /// The job is written as PostScript, which carries halftone screens and flatness: no + /// warnings that they are left to the device. + pub postscript: bool, } /// A printed job. @@ -141,7 +146,7 @@ pub fn keep_layers(nodes: &mut Vec>, which: PrintLayers) { } impl<'a> Job<'a> { - fn new(doc: &'a Document, set: &PrintSettings) -> Result { + fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; // Live effects print as their result (raster effects are rendered by the app first). let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned); @@ -163,13 +168,13 @@ impl<'a> Job<'a> { if separations && doc.color_mode == ColorMode::Rgb { warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into()); } - if separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { + if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); } if !separations && doc.layers.iter().any(|l| l.has_overprint()) { warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into()); } - if !set.graphics.auto_flatness { + if !postscript && !set.graphics.auto_flatness { warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); } Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings }) @@ -180,13 +185,14 @@ impl<'a> Job<'a> { self.inks.get(*self.plates.get(plate?)?) } - /// Every page, copies included, in print order: (layout, ink). - fn pages(&self, set: &PrintSettings) -> Vec<(usize, Option)> { + /// Every page, copies included, in print order: indices into `sheets`. + fn order(&self, set: &PrintSettings) -> Vec { let copies = set.copies.max(1) as usize; - let mut v: Vec<_> = if set.collate { - (0..copies).flat_map(|_| self.sheets.iter().copied()).collect() + let sheets = 0..self.sheets.len(); + let mut v: Vec = if set.collate { + (0..copies).flat_map(|_| sheets.clone()).collect() } else { - self.sheets.iter().flat_map(|s| std::iter::repeat_n(*s, copies)).collect() + sheets.flat_map(|s| std::iter::repeat_n(s, copies)).collect() }; if set.reverse { v.reverse(); @@ -197,7 +203,7 @@ impl<'a> Job<'a> { /// What printing `doc` with `set` makes: pages, tiles, inks and warnings. pub fn preview(doc: &Document, set: &PrintSettings) -> Result { - let job = Job::new(doc, set)?; + let job = Job::new(doc, set, false)?; let sheets = job .sheets .iter() @@ -220,61 +226,136 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result, + /// Document space → drawing space. + pub place: Affine, + /// The art drawn, in document space (the bleed box): what lies outside it is left out. + pub area: Rect, + /// Printer's marks, in drawing space (separated for the sheet's ink). + pub marks: Option, + /// Invert the page: paper black, ink clear (a film negative). + pub negative: bool, + /// The plate the sheet prints ([`PrintPlan::plates`]); `None`: composite. + pub plate: Option, + /// The ink of a separation, with its screen. + pub ink: Option, +} + +impl PlannedSheet { + fn sheet(&self) -> Sheet<'_> { + Sheet { + size: self.size, + trim: self.trim, + bleed: self.bleed, + view: self.view, + window: self.window, + place: self.place, + area: self.area, + clip: true, + marks: self.marks.as_ref(), + negative: self.negative, + } + } +} + +/// A laid-out job: the documents it draws, its sheets and the order they print in. +#[derive(Debug)] +pub struct PrintPlan<'a> { + /// The document as it prints: live effects applied, the layers that print. + pub doc: Cow<'a, Document>, + /// With separations, one grey document per ink that prints (its coverage). + pub plates: Vec, + /// One copy's sheets. + pub sheets: Vec, + /// Every page in print order, copies included: indices into `sheets`. + pub order: Vec, + pub title: String, + /// When the job ran (Unix seconds, UTC). + pub created: Option, + pub warnings: Vec, +} + +/// Lay out printing `doc` as `opts` say, for a writer to draw. +pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, PdfError> { + let set = &opts.settings; + let job = Job::new(doc, set, opts.postscript)?; + let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone()); + let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix); + let sep = Separator::new(&job.doc, set); + let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); + let marks = set.marks.printer_marks(); + let sheets = job + .sheets + .iter() + .filter_map(|(l, plate)| { + let l = job.layouts.get(*l)?; + let ink = job.ink(*plate); + let art = marks_art(&job.doc, &marks, l, ink, &title, created).map(|mut art| { + if let Some(ink) = ink { + sep.node(&ink.name, &mut art); + } + art + }); + Some(PlannedSheet { + size: l.size, + trim: l.page_trim, + bleed: l.page_bleed, + view: l.view, + window: l.window, + place: l.place, + area: l.area, + marks: art, + negative: set.output.image == PrintImage::Negative, + plate: *plate, + ink: ink.cloned(), + }) + }) + .collect(); + let order = job.order(set); + Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) +} + /// Print `doc` to a PDF as `opts` say. pub fn print(doc: &Document, opts: &PrintOptions) -> Result { - let set = &opts.settings; - let job = Job::new(doc, set)?; - let doc = &*job.doc; + let plan = plan(doc, opts)?; + let doc = &*plan.doc; let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() }; - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix); - let separations = set.output.mode == OutputMode::Separations; - let mut w = Writer::new(doc, &pdf, &title, created, !separations && doc.color_mode == ColorMode::Cmyk)?; + let separations = opts.settings.output.mode == OutputMode::Separations; + let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?; // One exporter per ink (one for composite), each drawing its own document. - let sep = Separator::new(doc, set); - let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); let mut exporters: Vec = - if separations { plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] }; + if separations { plan.plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] }; for ex in &mut exporters { ex.non_printing = true; - ex.intent = set.color.intent; + ex.intent = opts.settings.color.intent; } - let marks = set.marks.printer_marks(); - let pages = job.pages(set); - for (l, plate) in &pages { - let (Some(l), Some(ex)) = (job.layouts.get(*l), exporters.get_mut(plate.unwrap_or(0))) else { continue }; - let ink = job.ink(*plate); - let art = marks_art(doc, &marks, l, ink, &title, created).map(|mut art| { - if let Some(ink) = ink { - sep.node(&ink.name, &mut art); - } - art - }); - let sheet = Sheet { - size: l.size, - trim: l.page_trim, - bleed: l.page_bleed, - view: l.view, - window: l.window, - place: l.place, - area: l.area, - clip: true, - marks: art.as_ref(), - negative: set.output.image == PrintImage::Negative, - }; - w.page(ex, &sheet)?; + for &i in &plan.order { + let Some(s) = plan.sheets.get(i) else { continue }; + let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue }; + w.page(ex, &s.sheet())?; } for ex in exporters { w.absorb(ex); } let (bytes, more) = w.finish()?; - let mut warnings = job.warnings; + let mut warnings = plan.warnings; for m in more { if !warnings.contains(&m) { warnings.push(m); } } - Ok(PrintReport { bytes, pages: pages.len(), warnings }) + Ok(PrintReport { bytes, pages: plan.order.len(), warnings }) } /// The printer's marks of page `l` (`None` without marks), with its page information: the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6523394e..9765f498 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,8 @@ mod tests_pdfoutput; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_printps; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7d4f958f..2e6d1be2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", ), ( "print.printers", diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 71e134fd..8c92e45a 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -1,11 +1,13 @@ //! Printing through the platform. The host app installs a [`PrintService`] ([`crate::Services`]): //! the desktop hands the job to the system's print spooler, the web to the browser's print dialog. //! `file.print` with settings sends the engine's print-ready PDF (`file.print` in the engine) to -//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; -//! `print.printers` lists the printers. +//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; a +//! PostScript file (`format: "postscript"`, or a `.ps` path) is always saved. `print.printers` +//! lists the printers. use serde::Serialize; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; use crate::{VectorcraftApp, io}; @@ -50,19 +52,28 @@ pub fn printers(app: &mut VectorcraftApp) -> Value { /// `file.print` with settings: print the active document with `settings` (over the document's) /// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked -/// file, the web downloads it) with `toFile`, a `path` or no print service. +/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file +/// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved +/// the same way. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); - let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some(); + let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps"); + let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript; let mut q = json!({}); - if let Some(set) = p.get("settings").filter(|v| !v.is_null()) { - q["settings"] = set.clone(); + for k in ["settings", "format", "level", "flattenerPreset"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + q[k] = v.clone(); + } + } + if postscript { + // The path is taken off before the engine runs: it can't tell the format from it. + q["format"] = json!("postscript"); } if to_file || app.services.print.is_none() { if let Some(path) = s("path") { q["path"] = json!(path); } - let (path, mut v) = io::run_to_file(app, "file.print", "pdf", q)?; + let (path, mut v) = io::run_to_file(app, "file.print", if postscript { "ps" } else { "pdf" }, q)?; io::report_saved(app, &path, &v); if !to_file { app.ui.status.push_str(" (printing isn't available here: the job was saved as a PDF)"); diff --git a/crates/ui-egui/src/tests_printps.rs b/crates/ui-egui/src/tests_printps.rs new file mode 100644 index 00000000..2814c565 --- /dev/null +++ b/crates/ui-egui/src/tests_printps.rs @@ -0,0 +1,65 @@ +//! Print to a PostScript file through the app: the job is saved, never sent to the printer. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::print::{PrintJob, PrintService, Printer}; +use crate::{Services, VectorcraftApp}; + +/// What the app wrote: (path, bytes). +type Written = Rc)>>>; + +/// A printer that counts the jobs it is sent. +struct Counting(Rc>); + +impl PrintService for Counting { + fn printers(&mut self) -> Vec { + vec![Printer { name: "Office".into(), default: true }] + } + + fn print(&mut self, _: &PrintJob) -> Result { + *self.0.borrow_mut() += 1; + Ok("Sent".into()) + } +} + +#[test] +fn postscript_is_saved_not_printed() { + let sent = Rc::new(RefCell::new(0)); + let written: Written = Rc::default(); + let w = written.clone(); + let services = Services { + print: Some(Box::new(Counting(sent.clone()))), + pick_save: Some(Box::new(|pick: &crate::FilePick| Some(format!("/tmp/{}", pick.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80, "artboards": 2})).unwrap(); + // A .ps path: PostScript, at the level asked for. + let r = app.run("file.print", json!({"path": "/tmp/job.ps", "level": 2})).unwrap(); + assert_eq!((r["format"].clone(), r["printed"].clone(), r["pages"].clone()), (json!("postscript"), json!(false), json!(2)), "{r}"); + // No path: a picked .ps file. + let r = app.run("file.print", json!({"format": "postscript", "settings": {"copies": 2}})).unwrap(); + assert_eq!(r["pages"], 4); + let w = written.borrow(); + assert_eq!(w.len(), 2); + assert_eq!(w[0].0, "/tmp/job.ps"); + assert!(w[1].0.ends_with(".ps"), "{}", w[1].0); + for (_, bytes) in w.iter() { + assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n")); + } + assert!(String::from_utf8_lossy(&w[0].1).contains("%%LanguageLevel: 2")); + assert_eq!(*sent.borrow(), 0, "nothing went to the printer"); + drop(w); + // A PDF still goes to the printer. + app.run("file.print", json!({"settings": {"copies": 1}})).unwrap(); + assert_eq!(*sent.borrow(), 1); + assert!(app.run("file.print", json!({"format": "ps2"})).is_err()); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5045b53a..6b57c740 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1306,6 +1306,18 @@ as they do for art larger than the imageable area and for overprints in composit {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.pdf","settings":{"copies":2}}}} ``` +Print to a PostScript file: `file.print {format: "postscript"}` (the default for a `.ps` path) writes the same job as +PostScript, `level: 3` (default) or `2`, answering `format: "postscript"` (`"pdf"` otherwise; in the app the job is +saved, not sent to a printer). The file follows the +DSC: `%%Pages` is the page count (copies included), each `%%Page` sets its paper size, separations name their ink +(`%%PlateColor`) and set its halftone screen (`frequency`, `angle`, a round dot), a fixed flatness is written +(`setflat`), negatives invert the transfer, and the marks print in Registration. PostScript has no transparency: it +is flattened first with `flattenerPreset` (`medium` by default; `high`, `low` or a saved one), with a warning. + +```json +{"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/job.ps","level":2,"settings":{"output":{"mode":"separations"}}}}} +``` + ## Data Recovery Modified documents get recovery copies (`file.recovery.save`; the app runs it every `autosaveInterval` minutes while