Merge pull request #117 from storytold/pr/m4-eps-import

M4.91–M4.92: EPS and PostScript import, print to PostScript
This commit is contained in:
BFlat 2026-10-05 17:07:02 +07:00 committed by GitHub
commit 491b79dd9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 5521 additions and 150 deletions

View File

@ -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<Arc<Clip>>, 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<Arc<Node>> {
nest_at(doc, items, 0)
}
/// [`nest`] for the clips at `depth` and deeper.
fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = 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<Node> {
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 }))
}

View File

@ -7,6 +7,7 @@
pub mod appearance;
pub mod assets;
pub mod clipnest;
pub mod graph;
pub mod hit;
pub mod inks;

View File

@ -126,7 +126,7 @@ struct Params {
}
/// A string or number param as text (`2018` and `"2018"` alike).
fn text(v: &Value) -> String {
pub(crate) fn text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),

View File

@ -152,10 +152,7 @@ fn parse(p: &Value) -> Result<Settings> {
let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?;
let mut flatten = flattener(&q, &[])?;
flatten.preserve_overprints &= overprint == Overprint::Preserve;
let level = q.level.as_ref().map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let level = q.level.as_ref().map(super::dxf::text);
let eps = EpsOptions {
level: choice("level", level, Level::from_id, d.level, "2 or 3")?,
preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?,

View File

@ -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<Loaded> {
/// [`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<Loaded> {
// 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<Loaded>
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;

View File

@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 <image> 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 <image> 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;

View File

@ -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",

View File

@ -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<u8> {
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<Value> {
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<u8> {
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<u8> {
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"));
}

View File

@ -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();

View File

@ -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<CommandSpec> {
"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<Rect>) -> Result<Loaded> {
{
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<Rect>) -> Result<Loaded> {
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<Rect>) -> Result<Loaded> {
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<Node>> =
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<Node>> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() };
let on_board = |c: &&Arc<Node>| 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))

View File

@ -1,13 +1,16 @@
//! File → Print as a print-ready PDF (`file.print`), what it would print (`print.preview`) and the
//! print settings saved with the document (`print.setup`, [`vectorcraft_doc::Document::print_setup`]).
//! File → Print as a print-ready PDF or a PostScript file (`file.print`), what it would print
//! (`print.preview`) and the print settings saved with the document (`print.setup`,
//! [`vectorcraft_doc::Document::print_setup`]).
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_eps::{Level, PrintJob, PrintPage};
use vectorcraft_pdf::{PrintOptions, PrintSettings};
use super::fileio::pdf::{merge, pdf_error};
use super::fileio::write_or_return;
use super::fileio::{extension, write_or_return};
use super::flatten::{FlattenOptions, flatten_document};
use super::*;
const SETUP: &str = "print.setup";
@ -37,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print",
[],
Some("Cmd+P"),
"{settings?: {…print.setup settings} (over the document's), path?} print the document as a print-ready PDF, one page per sheet of paper (per tile, per ink, per copy) → {pages, path, bytes, warnings}; no path → {dataBase64, bytes, pages, warnings}. Raster effects print at the document's raster effects resolution; halftone screens and flatness are left to the output device",
"{settings?: {…print.setup settings} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
has_doc,
print
),
@ -93,13 +96,76 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
const PRINT: &str = "file.print";
/// Whether `file.print` writes PostScript: `format`, else a `.ps` path.
fn to_postscript(p: &Value) -> Result<bool> {
match str_param(p, "format") {
None => Ok(str_param(p, "path").is_some_and(|path| extension(path) == "ps")),
Some("postscript") => Ok(true),
Some("pdf") => Ok(false),
Some(f) => Err(bad(PRINT, format!("format `{f}`: pdf or postscript"))),
}
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.print";
let postscript = to_postscript(p)?;
let flattener = if postscript {
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
Some(
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
)
} else {
None
};
let doc = &s.doc()?.doc;
let set = settings(C, doc, p)?;
let set = settings(PRINT, doc, p)?;
// Raster effects print as images at the document's raster effects resolution.
let flat = super::rasterfx::flatten_raster_effects(doc);
let r =
vectorcraft_pdf::print(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(C, e))?;
write_or_return(str_param(p, "path"), &r.bytes, json!({ "pages": r.pages, "warnings": r.warnings }))
let doc = flat.as_ref().unwrap_or(doc);
let path = str_param(p, "path");
let Some(flattener) = flattener else {
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
};
let level = match p.get("level").filter(|v| !v.is_null()) {
Some(v) => {
let t = super::fileio::dxf::text(v);
Level::from_id(&t).ok_or_else(|| bad(PRINT, format!("level `{t}`: 2 or 3")))?
}
None => Level::default(),
};
// PostScript has no transparency.
let flat = flatten_document(doc, &flattener)?;
let mut warnings = vec![];
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
}
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
.map_err(|e| pdf_error(PRINT, e))?;
let pages: Vec<PrintPage> = plan
.sheets
.iter()
.map(|sh| PrintPage {
doc: sh.plate.and_then(|i| plan.plates.get(i)).unwrap_or(&plan.doc),
size: sh.size,
view: sh.view,
window: sh.window,
place: sh.place,
area: sh.area,
marks: sh.marks.as_ref(),
negative: sh.negative,
ink: sh.ink.as_ref().map(|i| (i.name.as_str(), i.frequency, i.angle)),
})
.collect();
let job = PrintJob { level, title: plan.title.clone(), created: plan.created, flatness };
let out = vectorcraft_eps::print(&pages, &plan.order, &job).map_err(|e| bad(PRINT, e))?;
for w in plan.warnings.iter().chain(&out.warnings) {
if !warnings.contains(w) {
warnings.push(w.clone());
}
}
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
}

View File

@ -1219,6 +1219,8 @@ mod tests_printpresets;
#[cfg(test)]
mod tests_printpreview;
#[cfg(test)]
mod tests_printps;
#[cfg(test)]
mod tests_proxyitems;
#[cfg(test)]
mod tests_rastersettings;

View File

@ -0,0 +1,96 @@
//! File → Print to a PostScript file: `file.print {format: "postscript"}` writes the job the PDF
//! print lays out as DSC-conforming PostScript.
use serde_json::{Value, json};
use super::*;
/// A document of `artboards` 300 × 200 artboards with a rectangle on the first.
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
s
}
/// `file.print {format: "postscript", …p}` → (the PostScript, the answer).
fn print_ps(s: &mut Session, mut p: Value) -> (String, Value) {
p["format"] = json!("postscript");
let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(String::from_utf8(bytes).unwrap(), v)
}
/// The values of DSC comment `key` (`%%Key:`), in order.
fn dsc(ps: &str, key: &str) -> Vec<String> {
let prefix = format!("%%{key}:");
ps.lines().filter_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()).collect()
}
#[test]
fn pages_equals_the_page_count() {
let mut s = session(3);
let (ps, v) = print_ps(&mut s, json!({}));
assert_eq!((v["pages"].clone(), v["format"].clone()), (json!(3), json!("postscript")));
assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n"));
assert_eq!(dsc(&ps, "Pages"), ["3"]);
assert_eq!(dsc(&ps, "Page").len(), 3);
assert_eq!(dsc(&ps, "LanguageLevel"), ["3"]);
// Copies, a range of artboards, and the level.
let (ps, v) = print_ps(&mut s, json!({"level": 2, "settings": {"copies": 2, "artboards": "range", "range": "1-2"}}));
assert_eq!((v["pages"].clone(), dsc(&ps, "Pages")), (json!(4), vec!["4".to_string()]));
assert_eq!(dsc(&ps, "Page").len(), 4);
assert_eq!(dsc(&ps, "LanguageLevel"), ["2"]);
// Letter paper, turned to the wide artboards.
assert_eq!(dsc(&ps, "PageBoundingBox")[0], "0 0 792 612");
}
#[test]
fn marks_and_separations_are_written() {
let mut s = session(1);
let (ps, _) = print_ps(&mut s, json!({"settings": {"marks": {"trim": true, "registration": true}}}));
// Marks print in Registration: on every ink.
assert!(ps.contains("/All /DeviceCMYK"), "{ps}");
let (none, _) = print_ps(&mut s, json!({}));
assert!(!none.contains("/All /DeviceCMYK"));
// Separations: a page per ink with its screen.
let (ps, v) =
print_ps(&mut s, json!({"settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "frequency": 120, "angle": 30}]}}}));
assert_eq!(v["pages"], 4);
assert_eq!(dsc(&ps, "PlateColor"), ["Cyan", "Magenta", "Yellow", "Black"]);
assert!(ps.contains("120 30 {dup mul exch dup mul add 1 exch sub} setscreen"));
// PostScript writes the screens, so no warning says they are left to the device.
assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("screens")), "{v}");
// Negatives and a fixed flatness.
let (ps, _) = print_ps(&mut s, json!({"settings": {"output": {"image": "negative"}, "graphics": {"autoFlatness": false, "flatness": 4}}}));
assert!(ps.contains("{1 exch sub} settransfer") && ps.contains("4 setflat"));
}
#[test]
fn a_ps_path_prints_postscript_and_pdf_stays_the_default() {
let mut s = session(1);
let v = s.execute("file.print", &json!({})).unwrap();
assert_eq!(v["format"], "pdf");
let path = std::env::temp_dir().join(format!("vc-printps-{}.ps", std::process::id()));
let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(v["format"], "postscript");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%!PS-Adobe-3.0"));
let _ = std::fs::remove_file(&path);
// The file reads back: the rectangle on the first page.
let back = vectorcraft_eps::import(&bytes).unwrap();
assert!(!back.document.layers[0].children().unwrap().is_empty());
for bad in [json!({"format": "ps2"}), json!({"format": "postscript", "level": 1}), json!({"format": "postscript", "flattenerPreset": "nope"})] {
assert!(matches!(s.execute("file.print", &bad), Err(EngineError::BadParams { .. })), "{bad}");
}
}
#[test]
fn transparency_is_flattened_for_postscript() {
let mut s = session(1);
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let (ps, v) = print_ps(&mut s, json!({}));
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened")), "{v}");
assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("opaque: EPS")), "{v}");
assert_eq!(dsc(&ps, "Pages"), ["1"]);
}

View File

@ -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::<u8>()), 0..12)) {
static SAMPLE: std::sync::OnceLock<Vec<u8>> = 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<u8> {
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<Value = String> {
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)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
"<< /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! {

View File

@ -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<Filter>),
Buf {
data: Vec<u8>,
pos: usize,
/// The samples are 8-bit RGB (or grey) whatever the image's colour space: a decoded JPEG.
rgb: Option<u8>,
},
}
fn io<T>(at: &str) -> Res<T> {
ps_err("ioerror", at)
}
/// The bytes `RunLengthDecode` makes of `raw`, and how many it read.
fn run_length(raw: &[u8]) -> Res<(Vec<u8>, 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<u8>, 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<u8>, usize)> {
let mut table: Vec<Vec<u8>> = (0..=255u8).map(|b| vec![b]).collect();
table.push(vec![]);
table.push(vec![]);
let (mut out, mut prev): (Vec<u8>, Option<Vec<u8>>) = (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<u8>, 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<str>,
/// `SubFileDecode`'s count and end-of-data string.
sub: (usize, Vec<u8>),
}
/// 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<u8>, usize, Option<u8>)> {
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<RefCell<Stream>>),
Str(Vec<u8>),
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<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
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<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
let mut out = Vec::with_capacity(n);
let mut high: Option<u8> = 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<RefCell<Stream>>) -> Res {
let chain = match &*f.borrow() {
Stream::Pending(c) => c.clone(),
_ => return Ok(()),
};
let mut buf: Option<(Vec<u8>, Option<u8>)> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Spec> {
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<f64> = 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::<Res<Vec<_>>>()?,
_ => vec![source(&src)?],
};
let mask_color: Option<Vec<u32>> = (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<Spec> {
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<Option<Pixels>> {
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<Vec<u8>> = 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<f64> = 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<Vec<u32>, [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<f64>,
/// User space → image space.
matrix: Affine,
/// One source, or one per component.
sources: Vec<Source>,
/// `MaskColor` (ImageType 4): samples that are transparent.
mask_color: Option<Vec<u32>>,
/// An image mask: whether its 1 bits paint.
stencil: Option<bool>,
}
/// Decoded pixels.
struct Pixels {
w: u32,
h: u32,
rgba: Vec<u8>,
matrix: Affine,
}
fn source(o: &Obj) -> Res<Source> {
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))
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<T>(what: &str) -> Res<T> {
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<u8> {
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<Option<Obj>> {
let t = self.token(0);
self.fresh = true;
t
}
fn token(&mut self, depth: usize) -> Res<Option<Obj>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<usize> {
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<Vec<u8>> {
let mut out = Vec::with_capacity(body.len() / 2);
let mut high: Option<u8> = 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<Obj> {
let first = w.bytes().next()?;
if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) {
return None;
}
if let Ok(i) = w.parse::<i64>() {
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))
}

View File

@ -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<String>,
/// 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<u32>,
}
/// 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<f64> = v.split_whitespace().map_while(|w| w.parse::<f64>().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<Imported, String> {
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<Document> {
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)
}

View File

@ -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<Key, Obj>;
/// A shared, mutable dictionary.
pub(crate) type DictRef = Rc<RefCell<Dict>>;
/// A dictionary key.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Key {
Name(Rc<str>),
Int(i64),
Bool(bool),
}
#[derive(Clone, Debug)]
pub(crate) enum Obj {
Null,
Bool(bool),
Int(i64),
Real(f64),
/// A literal name (`/name`).
Name(Rc<str>),
/// An executable name: looked up and run.
Exec(Rc<str>),
Str(Rc<RefCell<Vec<u8>>>),
/// An array; executable ones are procedures.
Array {
items: Rc<RefCell<Vec<Obj>>>,
exec: bool,
},
Dict(DictRef),
Op(Op),
Mark,
File(Rc<RefCell<Stream>>),
/// A `save` level (the graphics state depth it restores to).
Save(usize),
/// A graphics state object (`gstate`, `currentgstate`).
GState(Rc<GState>),
}
impl Obj {
pub fn name(s: &str) -> Self {
Self::Name(Rc::from(s))
}
pub fn string(bytes: Vec<u8>) -> Self {
Self::Str(Rc::new(RefCell::new(bytes)))
}
pub fn array(items: Vec<Obj>) -> Self {
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
}
pub fn proc(items: Vec<Obj>) -> 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<f64> {
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<Rc<str>> {
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<Key> {
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<RefCell<Vec<Obj>>>> {
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<T = ()> = Result<T, PsError>;
/// A PostScript error raised at `at`.
pub(crate) fn ps_err<T>(name: &'static str, at: &str) -> Res<T> {
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",
}

View File

@ -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<u8> {
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<Arc<Node>> {
d.layers[0].children().unwrap().clone()
}
/// Every object, down through groups, in order.
fn all(d: &Document) -> Vec<Arc<Node>> {
fn walk(n: &Arc<Node>, out: &mut Vec<Arc<Node>>) {
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 <ff0000 00ff00>] setcolorspace 1 setcolor 0 0 10 10 rectfill",
);
let fills: Vec<Paint> = 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<f32> = 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] <f0> 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);
}

View File

@ -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<DictRef> {
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}',
}
}

View File

@ -18,14 +18,25 @@
//! 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.
//! - `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};
@ -236,6 +247,25 @@ const THUMBNAIL: &str = "thumbnail";
/// Largest native document [`native`] inflates (bytes).
const MAX_NATIVE: u64 = 1 << 30;
/// The DSC comments every file of ours has after its version line: creator, title, date,
/// language level, data, and the spot colours `custom` with their CMYK equivalents.
pub(crate) fn comments(s: &mut String, title: &str, created: Option<i64>, level: Level, custom: &[(String, [f32; 4])]) {
use std::fmt::Write;
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(s, "%%Title: {}", ps::string(title));
if let Some(t) = created {
let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)");
}
let _ = writeln!(s, "%%LanguageLevel: {}", level.id());
s.push_str("%%DocumentData: Clean7Bit\n");
for (i, (name, cmyk)) in custom.iter().enumerate() {
let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name));
let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v)));
let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name));
}
}
/// The PostScript program: header comments, thumbnail, prolog, setup, the page and the trailer
/// with the native document.
fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4])], thumb: Option<&[u8]>) -> String {
@ -244,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);
@ -365,6 +383,12 @@ pub fn native(bytes: &[u8]) -> Option<Vec<u8>> {
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<Vec<u8>> {
read_data(sections(bytes)?.0, THUMBNAIL)

164
crates/eps/src/print/mod.rs Normal file
View File

@ -0,0 +1,164 @@
//! Print to a PostScript file: the sheets a print job laid out ([`PrintPage`]), each drawn by the
//! document walk the EPS writer uses, in one DSC-conforming file: a page per sheet with its paper
//! size, the ink and halftone screen of a separation, the flatness, and film negatives.
use std::fmt::Write;
use kurbo::Shape;
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect};
use crate::scene::{Page, Scene};
use crate::{EpsOptions, EpsOutput, Level, comments, ps};
/// One sheet of a print job (see the PDF writer's print plan for how the spaces relate).
#[derive(Clone, Debug)]
pub struct PrintPage<'a> {
/// The document the sheet draws: the job's, or a separation's plate.
pub doc: &'a Document,
/// The page size in points.
pub size: (f64, f64),
/// Drawing space → page space (y down from the top-left corner).
pub view: Affine,
/// The part of the drawing space that shows (a tile): art and marks are clipped to it.
pub window: Option<Rect>,
/// Document space → drawing space.
pub place: Affine,
/// The art drawn, in document space: what lies outside it is left out.
pub area: Rect,
/// Printer's marks, in drawing space.
pub marks: Option<&'a Node>,
/// A film negative: paper black, ink clear.
pub negative: bool,
/// A separation's ink: its name, screen ruling (lpi) and angle (degrees).
pub ink: Option<(&'a str, f64, f64)>,
}
/// The settings of a whole job.
#[derive(Clone, Debug, Default)]
pub struct PrintJob {
pub level: Level,
/// `%%Title`.
pub title: String,
/// `%%CreationDate` as Unix seconds (UTC).
pub created: Option<i64>,
/// The flatness curves are drawn with (device pixels); `None`: the device's own.
pub flatness: Option<f64>,
}
/// Every number of `a` finite?
fn finite(a: Affine) -> bool {
a.as_coeffs().iter().all(|v| v.is_finite())
}
/// The program that draws sheet `p` (its page setup and content, without the DSC page comment),
/// and what its walks found.
fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
let (w, h) = p.size;
let r = p.area;
if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) {
return Err(format!("page size {w} × {h}"));
}
if !finite(p.view) || !finite(p.place) {
return Err("a page's placement is not finite".into());
}
let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() };
let art_opts = opts(r);
let art = Scene::new(p.doc, &art_opts).run();
// The marks' area: the drawing space the page shows (within the tile).
let paper = Rect::new(0.0, 0.0, w, h);
let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper };
let shown = p.window.map_or(shown, |t| t.intersect(shown));
let marks_opts = opts(shown);
let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m));
let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024);
let (wi, hi) = (w.ceil() as i64, h.ceil() as i64);
let _ = writeln!(s, "%%PageBoundingBox: 0 0 {wi} {hi}");
if let Some((name, _, _)) = p.ink {
let _ = writeln!(s, "%%PlateColor: {name}");
}
s.push_str("%%BeginPageSetup\n");
let _ = writeln!(s, "/setpagedevice where {{pop << /PageSize [{} {}] >> setpagedevice}} if", ps::num(w), ps::num(h));
s.push_str("%%EndPageSetup\nVCdict begin\nq\n");
if p.negative {
// Ink becomes clear and paper black: the inverted transfer paints the paper's white black.
let _ = writeln!(s, "{{1 exch sub}} settransfer 1 g 0 0 {} {} rectfill", ps::num(w), ps::num(h));
}
if let Some((_, lpi, angle)) = p.ink {
// A round dot.
let _ = writeln!(s, "{} {} {{dup mul exch dup mul add 1 exch sub}} setscreen", ps::num(lpi), ps::num(angle));
}
if let Some(f) = job.flatness {
let _ = writeln!(s, "{} setflat", ps::num(f));
}
// Page space (y down) onto the paper (y up), then the drawing space.
let _ = writeln!(s, "[1 0 0 -1 0 {}] cm", ps::num(h));
let _ = writeln!(s, "{} cm", ps::matrix(p.view));
if let Some(t) = p.window {
ps::push_path(&mut s, &t.to_path(0.1));
s.push_str("W\n");
}
s.push_str("q\n");
let _ = writeln!(s, "{} cm", ps::matrix(p.place));
ps::push_path(&mut s, &r.to_path(0.1));
s.push_str("W\n");
s.push_str(&art.setup);
s.push_str(&art.body);
s.push_str("Q\n");
let mut found = vec![art];
if let Some(m) = marks {
// The marks' spot colour spaces are defined after the art is drawn: names may be reused.
s.push_str(&m.setup);
s.push_str(&m.body);
found.push(m);
}
s.push_str("Q\nend\nshowpage\n%%PageTrailer\n");
Ok((s, found))
}
/// Write the sheets `pages` in `order` (indices into `pages`; copies repeat them) as one
/// PostScript file of `order.len()` pages. Each sheet is drawn once and repeated for its copies.
pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result<EpsOutput, String> {
if order.is_empty() {
return Err("nothing to print".into());
}
let mut drawn = Vec::with_capacity(pages.len());
let mut custom: Vec<(String, [f32; 4])> = vec![];
let mut warnings: Vec<String> = vec![];
for p in pages {
let (s, found) = sheet(p, job)?;
for page in found {
for c in page.custom {
if !custom.iter().any(|(n, _)| *n == c.0) {
custom.push(c);
}
}
for w in page.warnings {
if !warnings.contains(&w) {
warnings.push(w);
}
}
}
drawn.push(s);
}
let (w, h) = pages.iter().fold((0.0f64, 0.0f64), |(w, h), p| (w.max(p.size.0), h.max(p.size.1)));
let size: usize = order.iter().filter_map(|i| drawn.get(*i)).map(String::len).sum();
let mut s = String::with_capacity(size + 4096);
s.push_str("%!PS-Adobe-3.0\n"); // brand-ok: the DSC version line every conforming file starts with
comments(&mut s, &job.title, job.created, job.level, &custom);
let _ = writeln!(s, "%%BoundingBox: 0 0 {} {}", w.ceil() as i64, h.ceil() as i64);
let _ = writeln!(s, "%%Pages: {}\n%%PageOrder: Ascend\n%%EndComments", order.len());
s.push_str("%%BeginProlog\n");
s.push_str(ps::PROLOG);
s.push_str("%%EndProlog\n%%BeginSetup\n%%EndSetup\n");
for (n, i) in order.iter().enumerate() {
let page = drawn.get(*i).ok_or("a page out of range")?;
let _ = writeln!(s, "%%Page: {0} {0}", n + 1);
s.push_str(page);
}
s.push_str("%%Trailer\n%%EOF\n");
Ok(EpsOutput { bytes: s.into_bytes(), warnings })
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,85 @@
//! PostScript print jobs: DSC page comments, the setup of each sheet, and a file the PostScript
//! reader reads back.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::*;
/// A 200 × 100 pt document with a red rectangle.
fn doc() -> Document {
let mut d = Document::new(200.0, 100.0);
let layer = d.layers[0].id;
let look = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 60.0)), look);
d.insert(Some(layer), 0, n).unwrap();
d
}
/// The document on a letter page, 36 pt in from its top-left corner.
fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> {
PrintPage {
doc: d,
size: (612.0, 792.0),
view: Affine::IDENTITY,
window: None,
place: Affine::translate((36.0, 36.0)),
area: d.artboards[0].rect,
marks,
negative: false,
ink: None,
}
}
fn text(out: &EpsOutput) -> String {
String::from_utf8(out.bytes.clone()).unwrap()
}
#[test]
fn pages_follow_dsc_and_repeat_for_copies() {
let d = doc();
let mark =
Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25));
let pages = [page(&d, None), PrintPage { size: (792.0, 612.0), ..page(&d, Some(&mark)) }];
let job = PrintJob { title: "Job".into(), created: Some(0), ..PrintJob::default() };
let out = print(&pages, &[0, 1, 0, 1], &job).unwrap();
let ps = text(&out);
assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n"));
let dsc = |key: &str| ps.lines().filter_map(|l| l.strip_prefix(key)).map(str::trim).collect::<Vec<_>>();
assert_eq!(dsc("%%Pages:"), ["4"]);
assert_eq!(dsc("%%Page:"), ["1 1", "2 2", "3 3", "4 4"]);
assert_eq!(dsc("%%PageBoundingBox:"), ["0 0 612 792", "0 0 792 612", "0 0 612 792", "0 0 792 612"]);
assert_eq!(dsc("%%BoundingBox:"), ["0 0 792 792"]);
assert_eq!(dsc("%%LanguageLevel:"), ["3"]);
assert_eq!(ps.matches("showpage").count(), 4);
assert!(ps.contains("<< /PageSize [792 612] >> setpagedevice"));
// The marks are drawn in Registration on the pages that have them.
assert_eq!(ps.matches("/All /DeviceCMYK").count(), 2, "{ps}");
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
// The PostScript reader reads the first page back: the rectangle 36 pt in, clipped to the
// artboard.
let back = crate::import(&out.bytes).unwrap();
let art = back.document.layers[0].children().unwrap().clone();
let clipped = art[0].children().unwrap();
assert_eq!(clipped[0].geometric_bounds().unwrap(), Rect::new(36.0, 36.0, 236.0, 136.0));
assert_eq!(clipped[1].geometric_bounds().unwrap(), Rect::new(56.0, 56.0, 156.0, 96.0));
assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings);
}
#[test]
fn separations_screens_flatness_negatives_and_tiles() {
let d = doc();
let sheet = PrintPage { negative: true, ink: Some(("Cyan", 85.0, 15.0)), window: Some(Rect::new(0.0, 0.0, 100.0, 100.0)), ..page(&d, None) };
let job = PrintJob { level: Level::Two, flatness: Some(3.0), ..PrintJob::default() };
let ps = text(&print(&[sheet], &[0], &job).unwrap());
assert!(ps.contains("%%PlateColor: Cyan\n"));
assert!(ps.contains("85 15 {dup mul exch dup mul add 1 exch sub} setscreen"));
assert!(ps.contains("{1 exch sub} settransfer 1 g 0 0 612 792 rectfill"));
assert!(ps.contains("3 setflat"));
assert!(ps.contains("%%LanguageLevel: 2"));
// The tile clips the page.
assert!(ps.contains("0 0 m\n100 0 l\n100 100 l\n0 100 l\n"), "{ps}");
assert!(print(&[], &[], &job).is_err());
assert!(print(&[PrintPage { size: (f64::NAN, 1.0), ..page(&d, None) }], &[0], &job).is_err());
}

View File

@ -132,6 +132,16 @@ impl<'a> Scene<'a> {
for l in &doc.layers {
self.node(l, false);
}
self.page()
}
/// Write one object (art outside the document: a print job's marks) instead of the layers.
pub fn run_node(mut self, n: &Node) -> Page {
self.node(n, false);
self.page()
}
fn page(self) -> Page {
let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect();
Page { setup: self.setup, body: self.body, custom, warnings: self.warnings }
}

View File

@ -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<Arc<Clip>>, 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<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = 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<Node> {
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<Imported, String> {
match crate::sniff(bytes) {

View File

@ -15,7 +15,8 @@
//! colours keep their numbers.
//!
//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`]
//! lists the pages, tiles and inks without writing the file.
//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other
//! writers (PostScript), which can write screens and flatness themselves.
mod layout;
mod plates;
@ -24,6 +25,7 @@ mod settings;
use std::borrow::Cow;
use std::sync::Arc;
use kurbo::{Affine, Rect};
use serde::Serialize;
use vectorcraft_doc::marks::PrinterMarks;
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
@ -51,6 +53,9 @@ pub struct PrintOptions {
pub created: Option<i64>,
/// Content streams uncompressed (readable operators, for tests and debugging).
pub uncompressed: bool,
/// The job is written as PostScript, which carries halftone screens and flatness: no
/// warnings that they are left to the device.
pub postscript: bool,
}
/// A printed job.
@ -141,7 +146,7 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
}
impl<'a> Job<'a> {
fn new(doc: &'a Document, set: &PrintSettings) -> Result<Self, PdfError> {
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result<Self, PdfError> {
set.check()?;
// Live effects print as their result (raster effects are rendered by the app first).
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
@ -163,13 +168,13 @@ impl<'a> Job<'a> {
if separations && doc.color_mode == ColorMode::Rgb {
warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into());
}
if separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into());
}
if !separations && doc.layers.iter().any(|l| l.has_overprint()) {
warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into());
}
if !set.graphics.auto_flatness {
if !postscript && !set.graphics.auto_flatness {
warnings.push("a fixed flatness is not written yet: the output device flattens curves".into());
}
Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings })
@ -180,13 +185,14 @@ impl<'a> Job<'a> {
self.inks.get(*self.plates.get(plate?)?)
}
/// Every page, copies included, in print order: (layout, ink).
fn pages(&self, set: &PrintSettings) -> Vec<(usize, Option<usize>)> {
/// Every page, copies included, in print order: indices into `sheets`.
fn order(&self, set: &PrintSettings) -> Vec<usize> {
let copies = set.copies.max(1) as usize;
let mut v: Vec<_> = if set.collate {
(0..copies).flat_map(|_| self.sheets.iter().copied()).collect()
let sheets = 0..self.sheets.len();
let mut v: Vec<usize> = if set.collate {
(0..copies).flat_map(|_| sheets.clone()).collect()
} else {
self.sheets.iter().flat_map(|s| std::iter::repeat_n(*s, copies)).collect()
sheets.flat_map(|s| std::iter::repeat_n(s, copies)).collect()
};
if set.reverse {
v.reverse();
@ -197,7 +203,7 @@ impl<'a> Job<'a> {
/// What printing `doc` with `set` makes: pages, tiles, inks and warnings.
pub fn preview(doc: &Document, set: &PrintSettings) -> Result<PrintPreview, PdfError> {
let job = Job::new(doc, set)?;
let job = Job::new(doc, set, false)?;
let sheets = job
.sheets
.iter()
@ -220,61 +226,136 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result<PrintPreview, PdfE
Ok(PrintPreview { pages: job.sheets.len() * set.copies.max(1) as usize, sheets, tiles: job.tiles, inks: job.inks, warnings: job.warnings })
}
/// One sheet of a laid-out job, ready for a writer (PDF here, PostScript in the EPS writer).
#[derive(Clone, Debug)]
pub struct PlannedSheet {
/// The page size in points.
pub size: (f64, f64),
/// The TrimBox and BleedBox, in page space (y down from the top-left corner).
pub trim: Rect,
pub bleed: Rect,
/// Drawing space → page space.
pub view: Affine,
/// The part of the drawing space that shows (a tile): art and marks are clipped to it.
pub window: Option<Rect>,
/// Document space → drawing space.
pub place: Affine,
/// The art drawn, in document space (the bleed box): what lies outside it is left out.
pub area: Rect,
/// Printer's marks, in drawing space (separated for the sheet's ink).
pub marks: Option<Node>,
/// Invert the page: paper black, ink clear (a film negative).
pub negative: bool,
/// The plate the sheet prints ([`PrintPlan::plates`]); `None`: composite.
pub plate: Option<usize>,
/// The ink of a separation, with its screen.
pub ink: Option<PrintInk>,
}
impl PlannedSheet {
fn sheet(&self) -> Sheet<'_> {
Sheet {
size: self.size,
trim: self.trim,
bleed: self.bleed,
view: self.view,
window: self.window,
place: self.place,
area: self.area,
clip: true,
marks: self.marks.as_ref(),
negative: self.negative,
}
}
}
/// A laid-out job: the documents it draws, its sheets and the order they print in.
#[derive(Debug)]
pub struct PrintPlan<'a> {
/// The document as it prints: live effects applied, the layers that print.
pub doc: Cow<'a, Document>,
/// With separations, one grey document per ink that prints (its coverage).
pub plates: Vec<Document>,
/// One copy's sheets.
pub sheets: Vec<PlannedSheet>,
/// Every page in print order, copies included: indices into `sheets`.
pub order: Vec<usize>,
pub title: String,
/// When the job ran (Unix seconds, UTC).
pub created: Option<i64>,
pub warnings: Vec<String>,
}
/// Lay out printing `doc` as `opts` say, for a writer to draw.
pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>, PdfError> {
let set = &opts.settings;
let job = Job::new(doc, set, opts.postscript)?;
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let sep = Separator::new(&job.doc, set);
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let marks = set.marks.printer_marks();
let sheets = job
.sheets
.iter()
.filter_map(|(l, plate)| {
let l = job.layouts.get(*l)?;
let ink = job.ink(*plate);
let art = marks_art(&job.doc, &marks, l, ink, &title, created).map(|mut art| {
if let Some(ink) = ink {
sep.node(&ink.name, &mut art);
}
art
});
Some(PlannedSheet {
size: l.size,
trim: l.page_trim,
bleed: l.page_bleed,
view: l.view,
window: l.window,
place: l.place,
area: l.area,
marks: art,
negative: set.output.image == PrintImage::Negative,
plate: *plate,
ink: ink.cloned(),
})
})
.collect();
let order = job.order(set);
Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc })
}
/// Print `doc` to a PDF as `opts` say.
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
let set = &opts.settings;
let job = Job::new(doc, set)?;
let doc = &*job.doc;
let plan = plan(doc, opts)?;
let doc = &*plan.doc;
let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let separations = set.output.mode == OutputMode::Separations;
let mut w = Writer::new(doc, &pdf, &title, created, !separations && doc.color_mode == ColorMode::Cmyk)?;
let separations = opts.settings.output.mode == OutputMode::Separations;
let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?;
// One exporter per ink (one for composite), each drawing its own document.
let sep = Separator::new(doc, set);
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let mut exporters: Vec<Exporter> =
if separations { plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] };
if separations { plan.plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] };
for ex in &mut exporters {
ex.non_printing = true;
ex.intent = set.color.intent;
ex.intent = opts.settings.color.intent;
}
let marks = set.marks.printer_marks();
let pages = job.pages(set);
for (l, plate) in &pages {
let (Some(l), Some(ex)) = (job.layouts.get(*l), exporters.get_mut(plate.unwrap_or(0))) else { continue };
let ink = job.ink(*plate);
let art = marks_art(doc, &marks, l, ink, &title, created).map(|mut art| {
if let Some(ink) = ink {
sep.node(&ink.name, &mut art);
}
art
});
let sheet = Sheet {
size: l.size,
trim: l.page_trim,
bleed: l.page_bleed,
view: l.view,
window: l.window,
place: l.place,
area: l.area,
clip: true,
marks: art.as_ref(),
negative: set.output.image == PrintImage::Negative,
};
w.page(ex, &sheet)?;
for &i in &plan.order {
let Some(s) = plan.sheets.get(i) else { continue };
let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue };
w.page(ex, &s.sheet())?;
}
for ex in exporters {
w.absorb(ex);
}
let (bytes, more) = w.finish()?;
let mut warnings = job.warnings;
let mut warnings = plan.warnings;
for m in more {
if !warnings.contains(&m) {
warnings.push(m);
}
}
Ok(PrintReport { bytes, pages: pages.len(), warnings })
Ok(PrintReport { bytes, pages: plan.order.len(), warnings })
}
/// The printer's marks of page `l` (`None` without marks), with its page information: the

View File

@ -58,6 +58,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_printps;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
mod tests_recovery;

View File

@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.print",
"Print…",
"Cmd+P",
"{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}",
"{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way",
),
(
"print.printers",

View File

@ -1,11 +1,13 @@
//! Printing through the platform. The host app installs a [`PrintService`] ([`crate::Services`]):
//! the desktop hands the job to the system's print spooler, the web to the browser's print dialog.
//! `file.print` with settings sends the engine's print-ready PDF (`file.print` in the engine) to
//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service;
//! `print.printers` lists the printers.
//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; a
//! PostScript file (`format: "postscript"`, or a `.ps` path) is always saved. `print.printers`
//! lists the printers.
use serde::Serialize;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use crate::{VectorcraftApp, io};
@ -50,19 +52,28 @@ pub fn printers(app: &mut VectorcraftApp) -> Value {
/// `file.print` with settings: print the active document with `settings` (over the document's)
/// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked
/// file, the web downloads it) with `toFile`, a `path` or no print service.
/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file
/// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved
/// the same way.
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some();
let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps");
let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript;
let mut q = json!({});
if let Some(set) = p.get("settings").filter(|v| !v.is_null()) {
q["settings"] = set.clone();
for k in ["settings", "format", "level", "flattenerPreset"] {
if let Some(v) = p.get(k).filter(|v| !v.is_null()) {
q[k] = v.clone();
}
}
if postscript {
// The path is taken off before the engine runs: it can't tell the format from it.
q["format"] = json!("postscript");
}
if to_file || app.services.print.is_none() {
if let Some(path) = s("path") {
q["path"] = json!(path);
}
let (path, mut v) = io::run_to_file(app, "file.print", "pdf", q)?;
let (path, mut v) = io::run_to_file(app, "file.print", if postscript { "ps" } else { "pdf" }, q)?;
io::report_saved(app, &path, &v);
if !to_file {
app.ui.status.push_str(" (printing isn't available here: the job was saved as a PDF)");

View File

@ -0,0 +1,65 @@
//! Print to a PostScript file through the app: the job is saved, never sent to the printer.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::print::{PrintJob, PrintService, Printer};
use crate::{Services, VectorcraftApp};
/// What the app wrote: (path, bytes).
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// A printer that counts the jobs it is sent.
struct Counting(Rc<RefCell<usize>>);
impl PrintService for Counting {
fn printers(&mut self) -> Vec<Printer> {
vec![Printer { name: "Office".into(), default: true }]
}
fn print(&mut self, _: &PrintJob) -> Result<String, String> {
*self.0.borrow_mut() += 1;
Ok("Sent".into())
}
}
#[test]
fn postscript_is_saved_not_printed() {
let sent = Rc::new(RefCell::new(0));
let written: Written = Rc::default();
let w = written.clone();
let services = Services {
print: Some(Box::new(Counting(sent.clone()))),
pick_save: Some(Box::new(|pick: &crate::FilePick| Some(format!("/tmp/{}", pick.name)))),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 80, "artboards": 2})).unwrap();
// A .ps path: PostScript, at the level asked for.
let r = app.run("file.print", json!({"path": "/tmp/job.ps", "level": 2})).unwrap();
assert_eq!((r["format"].clone(), r["printed"].clone(), r["pages"].clone()), (json!("postscript"), json!(false), json!(2)), "{r}");
// No path: a picked .ps file.
let r = app.run("file.print", json!({"format": "postscript", "settings": {"copies": 2}})).unwrap();
assert_eq!(r["pages"], 4);
let w = written.borrow();
assert_eq!(w.len(), 2);
assert_eq!(w[0].0, "/tmp/job.ps");
assert!(w[1].0.ends_with(".ps"), "{}", w[1].0);
for (_, bytes) in w.iter() {
assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n"));
}
assert!(String::from_utf8_lossy(&w[0].1).contains("%%LanguageLevel: 2"));
assert_eq!(*sent.borrow(), 0, "nothing went to the printer");
drop(w);
// A PDF still goes to the printer.
app.run("file.print", json!({"settings": {"copies": 1}})).unwrap();
assert_eq!(*sent.borrow(), 1);
assert!(app.run("file.print", json!({"format": "ps2"})).is_err());
}

View File

@ -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,
@ -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
@ -1521,3 +1533,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]}}}
```