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:
commit
491b79dd9c
|
|
@ -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 }))
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod clipnest;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
pub mod inks;
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ struct Params {
|
|||
}
|
||||
|
||||
/// A string or number param as text (`2018` and `"2018"` alike).
|
||||
fn text(v: &Value) -> String {
|
||||
pub(crate) fn text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
|
|
|
|||
|
|
@ -152,10 +152,7 @@ fn parse(p: &Value) -> Result<Settings> {
|
|||
let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?;
|
||||
let mut flatten = flattener(&q, &[])?;
|
||||
flatten.preserve_overprints &= overprint == Overprint::Preserve;
|
||||
let level = q.level.as_ref().map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
let level = q.level.as_ref().map(super::dxf::text);
|
||||
let eps = EpsOptions {
|
||||
level: choice("level", level, Level::from_id, d.level, "2 or 3")?,
|
||||
preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
//! File → Print as a print-ready PDF (`file.print`), what it would print (`print.preview`) and the
|
||||
//! print settings saved with the document (`print.setup`, [`vectorcraft_doc::Document::print_setup`]).
|
||||
//! File → Print as a print-ready PDF or a PostScript file (`file.print`), what it would print
|
||||
//! (`print.preview`) and the print settings saved with the document (`print.setup`,
|
||||
//! [`vectorcraft_doc::Document::print_setup`]).
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_eps::{Level, PrintJob, PrintPage};
|
||||
use vectorcraft_pdf::{PrintOptions, PrintSettings};
|
||||
|
||||
use super::fileio::pdf::{merge, pdf_error};
|
||||
use super::fileio::write_or_return;
|
||||
use super::fileio::{extension, write_or_return};
|
||||
use super::flatten::{FlattenOptions, flatten_document};
|
||||
use super::*;
|
||||
|
||||
const SETUP: &str = "print.setup";
|
||||
|
|
@ -37,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Print",
|
||||
[],
|
||||
Some("Cmd+P"),
|
||||
"{settings?: {…print.setup settings} (over the document's), path?} print the document as a print-ready PDF, one page per sheet of paper (per tile, per ink, per copy) → {pages, path, bytes, warnings}; no path → {dataBase64, bytes, pages, warnings}. Raster effects print at the document's raster effects resolution; halftone screens and flatness are left to the output device",
|
||||
"{settings?: {…print.setup settings} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
|
|
@ -93,13 +96,76 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(v)
|
||||
}
|
||||
|
||||
const PRINT: &str = "file.print";
|
||||
|
||||
/// Whether `file.print` writes PostScript: `format`, else a `.ps` path.
|
||||
fn to_postscript(p: &Value) -> Result<bool> {
|
||||
match str_param(p, "format") {
|
||||
None => Ok(str_param(p, "path").is_some_and(|path| extension(path) == "ps")),
|
||||
Some("postscript") => Ok(true),
|
||||
Some("pdf") => Ok(false),
|
||||
Some(f) => Err(bad(PRINT, format!("format `{f}`: pdf or postscript"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.print";
|
||||
let postscript = to_postscript(p)?;
|
||||
let flattener = if postscript {
|
||||
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
|
||||
Some(
|
||||
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
|
||||
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
let set = settings(PRINT, doc, p)?;
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let r =
|
||||
vectorcraft_pdf::print(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(C, e))?;
|
||||
write_or_return(str_param(p, "path"), &r.bytes, json!({ "pages": r.pages, "warnings": r.warnings }))
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let path = str_param(p, "path");
|
||||
let Some(flattener) = flattener else {
|
||||
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
};
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let t = super::fileio::dxf::text(v);
|
||||
Level::from_id(&t).ok_or_else(|| bad(PRINT, format!("level `{t}`: 2 or 3")))?
|
||||
}
|
||||
None => Level::default(),
|
||||
};
|
||||
// PostScript has no transparency.
|
||||
let flat = flatten_document(doc, &flattener)?;
|
||||
let mut warnings = vec![];
|
||||
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
|
||||
}
|
||||
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
|
||||
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
let pages: Vec<PrintPage> = plan
|
||||
.sheets
|
||||
.iter()
|
||||
.map(|sh| PrintPage {
|
||||
doc: sh.plate.and_then(|i| plan.plates.get(i)).unwrap_or(&plan.doc),
|
||||
size: sh.size,
|
||||
view: sh.view,
|
||||
window: sh.window,
|
||||
place: sh.place,
|
||||
area: sh.area,
|
||||
marks: sh.marks.as_ref(),
|
||||
negative: sh.negative,
|
||||
ink: sh.ink.as_ref().map(|i| (i.name.as_str(), i.frequency, i.angle)),
|
||||
})
|
||||
.collect();
|
||||
let job = PrintJob { level, title: plan.title.clone(), created: plan.created, flatness };
|
||||
let out = vectorcraft_eps::print(&pages, &plan.order, &job).map_err(|e| bad(PRINT, e))?;
|
||||
for w in plan.warnings.iter().chain(&out.warnings) {
|
||||
if !warnings.contains(w) {
|
||||
warnings.push(w.clone());
|
||||
}
|
||||
}
|
||||
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,8 @@ mod tests_printpresets;
|
|||
#[cfg(test)]
|
||||
mod tests_printpreview;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
//! File → Print to a PostScript file: `file.print {format: "postscript"}` writes the job the PDF
|
||||
//! print lays out as DSC-conforming PostScript.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document of `artboards` 300 × 200 artboards with a rectangle on the first.
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// `file.print {format: "postscript", …p}` → (the PostScript, the answer).
|
||||
fn print_ps(s: &mut Session, mut p: Value) -> (String, Value) {
|
||||
p["format"] = json!("postscript");
|
||||
let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(String::from_utf8(bytes).unwrap(), v)
|
||||
}
|
||||
|
||||
/// The values of DSC comment `key` (`%%Key:`), in order.
|
||||
fn dsc(ps: &str, key: &str) -> Vec<String> {
|
||||
let prefix = format!("%%{key}:");
|
||||
ps.lines().filter_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_equals_the_page_count() {
|
||||
let mut s = session(3);
|
||||
let (ps, v) = print_ps(&mut s, json!({}));
|
||||
assert_eq!((v["pages"].clone(), v["format"].clone()), (json!(3), json!("postscript")));
|
||||
assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n"));
|
||||
assert_eq!(dsc(&ps, "Pages"), ["3"]);
|
||||
assert_eq!(dsc(&ps, "Page").len(), 3);
|
||||
assert_eq!(dsc(&ps, "LanguageLevel"), ["3"]);
|
||||
// Copies, a range of artboards, and the level.
|
||||
let (ps, v) = print_ps(&mut s, json!({"level": 2, "settings": {"copies": 2, "artboards": "range", "range": "1-2"}}));
|
||||
assert_eq!((v["pages"].clone(), dsc(&ps, "Pages")), (json!(4), vec!["4".to_string()]));
|
||||
assert_eq!(dsc(&ps, "Page").len(), 4);
|
||||
assert_eq!(dsc(&ps, "LanguageLevel"), ["2"]);
|
||||
// Letter paper, turned to the wide artboards.
|
||||
assert_eq!(dsc(&ps, "PageBoundingBox")[0], "0 0 792 612");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_and_separations_are_written() {
|
||||
let mut s = session(1);
|
||||
let (ps, _) = print_ps(&mut s, json!({"settings": {"marks": {"trim": true, "registration": true}}}));
|
||||
// Marks print in Registration: on every ink.
|
||||
assert!(ps.contains("/All /DeviceCMYK"), "{ps}");
|
||||
let (none, _) = print_ps(&mut s, json!({}));
|
||||
assert!(!none.contains("/All /DeviceCMYK"));
|
||||
// Separations: a page per ink with its screen.
|
||||
let (ps, v) =
|
||||
print_ps(&mut s, json!({"settings": {"output": {"mode": "separations", "inks": [{"name": "Cyan", "frequency": 120, "angle": 30}]}}}));
|
||||
assert_eq!(v["pages"], 4);
|
||||
assert_eq!(dsc(&ps, "PlateColor"), ["Cyan", "Magenta", "Yellow", "Black"]);
|
||||
assert!(ps.contains("120 30 {dup mul exch dup mul add 1 exch sub} setscreen"));
|
||||
// PostScript writes the screens, so no warning says they are left to the device.
|
||||
assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("screens")), "{v}");
|
||||
// Negatives and a fixed flatness.
|
||||
let (ps, _) = print_ps(&mut s, json!({"settings": {"output": {"image": "negative"}, "graphics": {"autoFlatness": false, "flatness": 4}}}));
|
||||
assert!(ps.contains("{1 exch sub} settransfer") && ps.contains("4 setflat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ps_path_prints_postscript_and_pdf_stays_the_default() {
|
||||
let mut s = session(1);
|
||||
let v = s.execute("file.print", &json!({})).unwrap();
|
||||
assert_eq!(v["format"], "pdf");
|
||||
let path = std::env::temp_dir().join(format!("vc-printps-{}.ps", std::process::id()));
|
||||
let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(v["format"], "postscript");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"%!PS-Adobe-3.0"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// The file reads back: the rectangle on the first page.
|
||||
let back = vectorcraft_eps::import(&bytes).unwrap();
|
||||
assert!(!back.document.layers[0].children().unwrap().is_empty());
|
||||
for bad in [json!({"format": "ps2"}), json!({"format": "postscript", "level": 1}), json!({"format": "postscript", "flattenerPreset": "nope"})] {
|
||||
assert!(matches!(s.execute("file.print", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_is_flattened_for_postscript() {
|
||||
let mut s = session(1);
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let (ps, v) = print_ps(&mut s, json!({}));
|
||||
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened")), "{v}");
|
||||
assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("opaque: EPS")), "{v}");
|
||||
assert_eq!(dsc(&ps, "Pages"), ["1"]);
|
||||
}
|
||||
|
|
@ -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! {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}',
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
//! Print to a PostScript file: the sheets a print job laid out ([`PrintPage`]), each drawn by the
|
||||
//! document walk the EPS writer uses, in one DSC-conforming file: a page per sheet with its paper
|
||||
//! size, the ink and halftone screen of a separation, the flatness, and film negatives.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::scene::{Page, Scene};
|
||||
use crate::{EpsOptions, EpsOutput, Level, comments, ps};
|
||||
|
||||
/// One sheet of a print job (see the PDF writer's print plan for how the spaces relate).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PrintPage<'a> {
|
||||
/// The document the sheet draws: the job's, or a separation's plate.
|
||||
pub doc: &'a Document,
|
||||
/// The page size in points.
|
||||
pub size: (f64, f64),
|
||||
/// Drawing space → page space (y down from the top-left corner).
|
||||
pub view: Affine,
|
||||
/// The part of the drawing space that shows (a tile): art and marks are clipped to it.
|
||||
pub window: Option<Rect>,
|
||||
/// Document space → drawing space.
|
||||
pub place: Affine,
|
||||
/// The art drawn, in document space: what lies outside it is left out.
|
||||
pub area: Rect,
|
||||
/// Printer's marks, in drawing space.
|
||||
pub marks: Option<&'a Node>,
|
||||
/// A film negative: paper black, ink clear.
|
||||
pub negative: bool,
|
||||
/// A separation's ink: its name, screen ruling (lpi) and angle (degrees).
|
||||
pub ink: Option<(&'a str, f64, f64)>,
|
||||
}
|
||||
|
||||
/// The settings of a whole job.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PrintJob {
|
||||
pub level: Level,
|
||||
/// `%%Title`.
|
||||
pub title: String,
|
||||
/// `%%CreationDate` as Unix seconds (UTC).
|
||||
pub created: Option<i64>,
|
||||
/// The flatness curves are drawn with (device pixels); `None`: the device's own.
|
||||
pub flatness: Option<f64>,
|
||||
}
|
||||
|
||||
/// Every number of `a` finite?
|
||||
fn finite(a: Affine) -> bool {
|
||||
a.as_coeffs().iter().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// The program that draws sheet `p` (its page setup and content, without the DSC page comment),
|
||||
/// and what its walks found.
|
||||
fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
|
||||
let (w, h) = p.size;
|
||||
let r = p.area;
|
||||
if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) {
|
||||
return Err(format!("page size {w} × {h}"));
|
||||
}
|
||||
if !finite(p.view) || !finite(p.place) {
|
||||
return Err("a page's placement is not finite".into());
|
||||
}
|
||||
let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() };
|
||||
let art_opts = opts(r);
|
||||
let art = Scene::new(p.doc, &art_opts).run();
|
||||
// The marks' area: the drawing space the page shows (within the tile).
|
||||
let paper = Rect::new(0.0, 0.0, w, h);
|
||||
let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper };
|
||||
let shown = p.window.map_or(shown, |t| t.intersect(shown));
|
||||
let marks_opts = opts(shown);
|
||||
let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m));
|
||||
let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024);
|
||||
let (wi, hi) = (w.ceil() as i64, h.ceil() as i64);
|
||||
let _ = writeln!(s, "%%PageBoundingBox: 0 0 {wi} {hi}");
|
||||
if let Some((name, _, _)) = p.ink {
|
||||
let _ = writeln!(s, "%%PlateColor: {name}");
|
||||
}
|
||||
s.push_str("%%BeginPageSetup\n");
|
||||
let _ = writeln!(s, "/setpagedevice where {{pop << /PageSize [{} {}] >> setpagedevice}} if", ps::num(w), ps::num(h));
|
||||
s.push_str("%%EndPageSetup\nVCdict begin\nq\n");
|
||||
if p.negative {
|
||||
// Ink becomes clear and paper black: the inverted transfer paints the paper's white black.
|
||||
let _ = writeln!(s, "{{1 exch sub}} settransfer 1 g 0 0 {} {} rectfill", ps::num(w), ps::num(h));
|
||||
}
|
||||
if let Some((_, lpi, angle)) = p.ink {
|
||||
// A round dot.
|
||||
let _ = writeln!(s, "{} {} {{dup mul exch dup mul add 1 exch sub}} setscreen", ps::num(lpi), ps::num(angle));
|
||||
}
|
||||
if let Some(f) = job.flatness {
|
||||
let _ = writeln!(s, "{} setflat", ps::num(f));
|
||||
}
|
||||
// Page space (y down) onto the paper (y up), then the drawing space.
|
||||
let _ = writeln!(s, "[1 0 0 -1 0 {}] cm", ps::num(h));
|
||||
let _ = writeln!(s, "{} cm", ps::matrix(p.view));
|
||||
if let Some(t) = p.window {
|
||||
ps::push_path(&mut s, &t.to_path(0.1));
|
||||
s.push_str("W\n");
|
||||
}
|
||||
s.push_str("q\n");
|
||||
let _ = writeln!(s, "{} cm", ps::matrix(p.place));
|
||||
ps::push_path(&mut s, &r.to_path(0.1));
|
||||
s.push_str("W\n");
|
||||
s.push_str(&art.setup);
|
||||
s.push_str(&art.body);
|
||||
s.push_str("Q\n");
|
||||
let mut found = vec![art];
|
||||
if let Some(m) = marks {
|
||||
// The marks' spot colour spaces are defined after the art is drawn: names may be reused.
|
||||
s.push_str(&m.setup);
|
||||
s.push_str(&m.body);
|
||||
found.push(m);
|
||||
}
|
||||
s.push_str("Q\nend\nshowpage\n%%PageTrailer\n");
|
||||
Ok((s, found))
|
||||
}
|
||||
|
||||
/// Write the sheets `pages` in `order` (indices into `pages`; copies repeat them) as one
|
||||
/// PostScript file of `order.len()` pages. Each sheet is drawn once and repeated for its copies.
|
||||
pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result<EpsOutput, String> {
|
||||
if order.is_empty() {
|
||||
return Err("nothing to print".into());
|
||||
}
|
||||
let mut drawn = Vec::with_capacity(pages.len());
|
||||
let mut custom: Vec<(String, [f32; 4])> = vec![];
|
||||
let mut warnings: Vec<String> = vec![];
|
||||
for p in pages {
|
||||
let (s, found) = sheet(p, job)?;
|
||||
for page in found {
|
||||
for c in page.custom {
|
||||
if !custom.iter().any(|(n, _)| *n == c.0) {
|
||||
custom.push(c);
|
||||
}
|
||||
}
|
||||
for w in page.warnings {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
drawn.push(s);
|
||||
}
|
||||
let (w, h) = pages.iter().fold((0.0f64, 0.0f64), |(w, h), p| (w.max(p.size.0), h.max(p.size.1)));
|
||||
let size: usize = order.iter().filter_map(|i| drawn.get(*i)).map(String::len).sum();
|
||||
let mut s = String::with_capacity(size + 4096);
|
||||
s.push_str("%!PS-Adobe-3.0\n"); // brand-ok: the DSC version line every conforming file starts with
|
||||
comments(&mut s, &job.title, job.created, job.level, &custom);
|
||||
let _ = writeln!(s, "%%BoundingBox: 0 0 {} {}", w.ceil() as i64, h.ceil() as i64);
|
||||
let _ = writeln!(s, "%%Pages: {}\n%%PageOrder: Ascend\n%%EndComments", order.len());
|
||||
s.push_str("%%BeginProlog\n");
|
||||
s.push_str(ps::PROLOG);
|
||||
s.push_str("%%EndProlog\n%%BeginSetup\n%%EndSetup\n");
|
||||
for (n, i) in order.iter().enumerate() {
|
||||
let page = drawn.get(*i).ok_or("a page out of range")?;
|
||||
let _ = writeln!(s, "%%Page: {0} {0}", n + 1);
|
||||
s.push_str(page);
|
||||
}
|
||||
s.push_str("%%Trailer\n%%EOF\n");
|
||||
Ok(EpsOutput { bytes: s.into_bytes(), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
//! PostScript print jobs: DSC page comments, the setup of each sheet, and a file the PostScript
|
||||
//! reader reads back.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 200 × 100 pt document with a red rectangle.
|
||||
fn doc() -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let look = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 60.0)), look);
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The document on a letter page, 36 pt in from its top-left corner.
|
||||
fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> {
|
||||
PrintPage {
|
||||
doc: d,
|
||||
size: (612.0, 792.0),
|
||||
view: Affine::IDENTITY,
|
||||
window: None,
|
||||
place: Affine::translate((36.0, 36.0)),
|
||||
area: d.artboards[0].rect,
|
||||
marks,
|
||||
negative: false,
|
||||
ink: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(out: &EpsOutput) -> String {
|
||||
String::from_utf8(out.bytes.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_follow_dsc_and_repeat_for_copies() {
|
||||
let d = doc();
|
||||
let mark =
|
||||
Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25));
|
||||
let pages = [page(&d, None), PrintPage { size: (792.0, 612.0), ..page(&d, Some(&mark)) }];
|
||||
let job = PrintJob { title: "Job".into(), created: Some(0), ..PrintJob::default() };
|
||||
let out = print(&pages, &[0, 1, 0, 1], &job).unwrap();
|
||||
let ps = text(&out);
|
||||
assert!(ps.starts_with("%!PS-Adobe-3.0\n") && ps.ends_with("%%EOF\n"));
|
||||
let dsc = |key: &str| ps.lines().filter_map(|l| l.strip_prefix(key)).map(str::trim).collect::<Vec<_>>();
|
||||
assert_eq!(dsc("%%Pages:"), ["4"]);
|
||||
assert_eq!(dsc("%%Page:"), ["1 1", "2 2", "3 3", "4 4"]);
|
||||
assert_eq!(dsc("%%PageBoundingBox:"), ["0 0 612 792", "0 0 792 612", "0 0 612 792", "0 0 792 612"]);
|
||||
assert_eq!(dsc("%%BoundingBox:"), ["0 0 792 792"]);
|
||||
assert_eq!(dsc("%%LanguageLevel:"), ["3"]);
|
||||
assert_eq!(ps.matches("showpage").count(), 4);
|
||||
assert!(ps.contains("<< /PageSize [792 612] >> setpagedevice"));
|
||||
// The marks are drawn in Registration on the pages that have them.
|
||||
assert_eq!(ps.matches("/All /DeviceCMYK").count(), 2, "{ps}");
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
// The PostScript reader reads the first page back: the rectangle 36 pt in, clipped to the
|
||||
// artboard.
|
||||
let back = crate::import(&out.bytes).unwrap();
|
||||
let art = back.document.layers[0].children().unwrap().clone();
|
||||
let clipped = art[0].children().unwrap();
|
||||
assert_eq!(clipped[0].geometric_bounds().unwrap(), Rect::new(36.0, 36.0, 236.0, 136.0));
|
||||
assert_eq!(clipped[1].geometric_bounds().unwrap(), Rect::new(56.0, 56.0, 156.0, 96.0));
|
||||
assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separations_screens_flatness_negatives_and_tiles() {
|
||||
let d = doc();
|
||||
let sheet = PrintPage { negative: true, ink: Some(("Cyan", 85.0, 15.0)), window: Some(Rect::new(0.0, 0.0, 100.0, 100.0)), ..page(&d, None) };
|
||||
let job = PrintJob { level: Level::Two, flatness: Some(3.0), ..PrintJob::default() };
|
||||
let ps = text(&print(&[sheet], &[0], &job).unwrap());
|
||||
assert!(ps.contains("%%PlateColor: Cyan\n"));
|
||||
assert!(ps.contains("85 15 {dup mul exch dup mul add 1 exch sub} setscreen"));
|
||||
assert!(ps.contains("{1 exch sub} settransfer 1 g 0 0 612 792 rectfill"));
|
||||
assert!(ps.contains("3 setflat"));
|
||||
assert!(ps.contains("%%LanguageLevel: 2"));
|
||||
// The tile clips the page.
|
||||
assert!(ps.contains("0 0 m\n100 0 l\n100 100 l\n0 100 l\n"), "{ps}");
|
||||
assert!(print(&[], &[], &job).is_err());
|
||||
assert!(print(&[PrintPage { size: (f64::NAN, 1.0), ..page(&d, None) }], &[0], &job).is_err());
|
||||
}
|
||||
|
|
@ -132,6 +132,16 @@ impl<'a> Scene<'a> {
|
|||
for l in &doc.layers {
|
||||
self.node(l, false);
|
||||
}
|
||||
self.page()
|
||||
}
|
||||
|
||||
/// Write one object (art outside the document: a print job's marks) instead of the layers.
|
||||
pub fn run_node(mut self, n: &Node) -> Page {
|
||||
self.node(n, false);
|
||||
self.page()
|
||||
}
|
||||
|
||||
fn page(self) -> Page {
|
||||
let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect();
|
||||
Page { setup: self.setup, body: self.body, custom, warnings: self.warnings }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
//! colours keep their numbers.
|
||||
//!
|
||||
//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`]
|
||||
//! lists the pages, tiles and inks without writing the file.
|
||||
//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other
|
||||
//! writers (PostScript), which can write screens and flatness themselves.
|
||||
|
||||
mod layout;
|
||||
mod plates;
|
||||
|
|
@ -24,6 +25,7 @@ mod settings;
|
|||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
use serde::Serialize;
|
||||
use vectorcraft_doc::marks::PrinterMarks;
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
|
|
@ -51,6 +53,9 @@ pub struct PrintOptions {
|
|||
pub created: Option<i64>,
|
||||
/// Content streams uncompressed (readable operators, for tests and debugging).
|
||||
pub uncompressed: bool,
|
||||
/// The job is written as PostScript, which carries halftone screens and flatness: no
|
||||
/// warnings that they are left to the device.
|
||||
pub postscript: bool,
|
||||
}
|
||||
|
||||
/// A printed job.
|
||||
|
|
@ -141,7 +146,7 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
|
|||
}
|
||||
|
||||
impl<'a> Job<'a> {
|
||||
fn new(doc: &'a Document, set: &PrintSettings) -> Result<Self, PdfError> {
|
||||
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result<Self, PdfError> {
|
||||
set.check()?;
|
||||
// Live effects print as their result (raster effects are rendered by the app first).
|
||||
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
|
||||
|
|
@ -163,13 +168,13 @@ impl<'a> Job<'a> {
|
|||
if separations && doc.color_mode == ColorMode::Rgb {
|
||||
warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into());
|
||||
}
|
||||
if separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
|
||||
if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
|
||||
warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into());
|
||||
}
|
||||
if !separations && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into());
|
||||
}
|
||||
if !set.graphics.auto_flatness {
|
||||
if !postscript && !set.graphics.auto_flatness {
|
||||
warnings.push("a fixed flatness is not written yet: the output device flattens curves".into());
|
||||
}
|
||||
Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings })
|
||||
|
|
@ -180,13 +185,14 @@ impl<'a> Job<'a> {
|
|||
self.inks.get(*self.plates.get(plate?)?)
|
||||
}
|
||||
|
||||
/// Every page, copies included, in print order: (layout, ink).
|
||||
fn pages(&self, set: &PrintSettings) -> Vec<(usize, Option<usize>)> {
|
||||
/// Every page, copies included, in print order: indices into `sheets`.
|
||||
fn order(&self, set: &PrintSettings) -> Vec<usize> {
|
||||
let copies = set.copies.max(1) as usize;
|
||||
let mut v: Vec<_> = if set.collate {
|
||||
(0..copies).flat_map(|_| self.sheets.iter().copied()).collect()
|
||||
let sheets = 0..self.sheets.len();
|
||||
let mut v: Vec<usize> = if set.collate {
|
||||
(0..copies).flat_map(|_| sheets.clone()).collect()
|
||||
} else {
|
||||
self.sheets.iter().flat_map(|s| std::iter::repeat_n(*s, copies)).collect()
|
||||
sheets.flat_map(|s| std::iter::repeat_n(s, copies)).collect()
|
||||
};
|
||||
if set.reverse {
|
||||
v.reverse();
|
||||
|
|
@ -197,7 +203,7 @@ impl<'a> Job<'a> {
|
|||
|
||||
/// What printing `doc` with `set` makes: pages, tiles, inks and warnings.
|
||||
pub fn preview(doc: &Document, set: &PrintSettings) -> Result<PrintPreview, PdfError> {
|
||||
let job = Job::new(doc, set)?;
|
||||
let job = Job::new(doc, set, false)?;
|
||||
let sheets = job
|
||||
.sheets
|
||||
.iter()
|
||||
|
|
@ -220,61 +226,136 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result<PrintPreview, PdfE
|
|||
Ok(PrintPreview { pages: job.sheets.len() * set.copies.max(1) as usize, sheets, tiles: job.tiles, inks: job.inks, warnings: job.warnings })
|
||||
}
|
||||
|
||||
/// One sheet of a laid-out job, ready for a writer (PDF here, PostScript in the EPS writer).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlannedSheet {
|
||||
/// The page size in points.
|
||||
pub size: (f64, f64),
|
||||
/// The TrimBox and BleedBox, in page space (y down from the top-left corner).
|
||||
pub trim: Rect,
|
||||
pub bleed: Rect,
|
||||
/// Drawing space → page space.
|
||||
pub view: Affine,
|
||||
/// The part of the drawing space that shows (a tile): art and marks are clipped to it.
|
||||
pub window: Option<Rect>,
|
||||
/// Document space → drawing space.
|
||||
pub place: Affine,
|
||||
/// The art drawn, in document space (the bleed box): what lies outside it is left out.
|
||||
pub area: Rect,
|
||||
/// Printer's marks, in drawing space (separated for the sheet's ink).
|
||||
pub marks: Option<Node>,
|
||||
/// Invert the page: paper black, ink clear (a film negative).
|
||||
pub negative: bool,
|
||||
/// The plate the sheet prints ([`PrintPlan::plates`]); `None`: composite.
|
||||
pub plate: Option<usize>,
|
||||
/// The ink of a separation, with its screen.
|
||||
pub ink: Option<PrintInk>,
|
||||
}
|
||||
|
||||
impl PlannedSheet {
|
||||
fn sheet(&self) -> Sheet<'_> {
|
||||
Sheet {
|
||||
size: self.size,
|
||||
trim: self.trim,
|
||||
bleed: self.bleed,
|
||||
view: self.view,
|
||||
window: self.window,
|
||||
place: self.place,
|
||||
area: self.area,
|
||||
clip: true,
|
||||
marks: self.marks.as_ref(),
|
||||
negative: self.negative,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A laid-out job: the documents it draws, its sheets and the order they print in.
|
||||
#[derive(Debug)]
|
||||
pub struct PrintPlan<'a> {
|
||||
/// The document as it prints: live effects applied, the layers that print.
|
||||
pub doc: Cow<'a, Document>,
|
||||
/// With separations, one grey document per ink that prints (its coverage).
|
||||
pub plates: Vec<Document>,
|
||||
/// One copy's sheets.
|
||||
pub sheets: Vec<PlannedSheet>,
|
||||
/// Every page in print order, copies included: indices into `sheets`.
|
||||
pub order: Vec<usize>,
|
||||
pub title: String,
|
||||
/// When the job ran (Unix seconds, UTC).
|
||||
pub created: Option<i64>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Lay out printing `doc` as `opts` say, for a writer to draw.
|
||||
pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>, PdfError> {
|
||||
let set = &opts.settings;
|
||||
let job = Job::new(doc, set, opts.postscript)?;
|
||||
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let sep = Separator::new(&job.doc, set);
|
||||
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
|
||||
let marks = set.marks.printer_marks();
|
||||
let sheets = job
|
||||
.sheets
|
||||
.iter()
|
||||
.filter_map(|(l, plate)| {
|
||||
let l = job.layouts.get(*l)?;
|
||||
let ink = job.ink(*plate);
|
||||
let art = marks_art(&job.doc, &marks, l, ink, &title, created).map(|mut art| {
|
||||
if let Some(ink) = ink {
|
||||
sep.node(&ink.name, &mut art);
|
||||
}
|
||||
art
|
||||
});
|
||||
Some(PlannedSheet {
|
||||
size: l.size,
|
||||
trim: l.page_trim,
|
||||
bleed: l.page_bleed,
|
||||
view: l.view,
|
||||
window: l.window,
|
||||
place: l.place,
|
||||
area: l.area,
|
||||
marks: art,
|
||||
negative: set.output.image == PrintImage::Negative,
|
||||
plate: *plate,
|
||||
ink: ink.cloned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let order = job.order(set);
|
||||
Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc })
|
||||
}
|
||||
|
||||
/// Print `doc` to a PDF as `opts` say.
|
||||
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
let set = &opts.settings;
|
||||
let job = Job::new(doc, set)?;
|
||||
let doc = &*job.doc;
|
||||
let plan = plan(doc, opts)?;
|
||||
let doc = &*plan.doc;
|
||||
let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() };
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
let separations = set.output.mode == OutputMode::Separations;
|
||||
let mut w = Writer::new(doc, &pdf, &title, created, !separations && doc.color_mode == ColorMode::Cmyk)?;
|
||||
let separations = opts.settings.output.mode == OutputMode::Separations;
|
||||
let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?;
|
||||
// One exporter per ink (one for composite), each drawing its own document.
|
||||
let sep = Separator::new(doc, set);
|
||||
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
|
||||
let mut exporters: Vec<Exporter> =
|
||||
if separations { plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] };
|
||||
if separations { plan.plates.iter().map(|d| Exporter::new(d, &pdf)).collect() } else { vec![Exporter::new(doc, &pdf)] };
|
||||
for ex in &mut exporters {
|
||||
ex.non_printing = true;
|
||||
ex.intent = set.color.intent;
|
||||
ex.intent = opts.settings.color.intent;
|
||||
}
|
||||
let marks = set.marks.printer_marks();
|
||||
let pages = job.pages(set);
|
||||
for (l, plate) in &pages {
|
||||
let (Some(l), Some(ex)) = (job.layouts.get(*l), exporters.get_mut(plate.unwrap_or(0))) else { continue };
|
||||
let ink = job.ink(*plate);
|
||||
let art = marks_art(doc, &marks, l, ink, &title, created).map(|mut art| {
|
||||
if let Some(ink) = ink {
|
||||
sep.node(&ink.name, &mut art);
|
||||
}
|
||||
art
|
||||
});
|
||||
let sheet = Sheet {
|
||||
size: l.size,
|
||||
trim: l.page_trim,
|
||||
bleed: l.page_bleed,
|
||||
view: l.view,
|
||||
window: l.window,
|
||||
place: l.place,
|
||||
area: l.area,
|
||||
clip: true,
|
||||
marks: art.as_ref(),
|
||||
negative: set.output.image == PrintImage::Negative,
|
||||
};
|
||||
w.page(ex, &sheet)?;
|
||||
for &i in &plan.order {
|
||||
let Some(s) = plan.sheets.get(i) else { continue };
|
||||
let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue };
|
||||
w.page(ex, &s.sheet())?;
|
||||
}
|
||||
for ex in exporters {
|
||||
w.absorb(ex);
|
||||
}
|
||||
let (bytes, more) = w.finish()?;
|
||||
let mut warnings = job.warnings;
|
||||
let mut warnings = plan.warnings;
|
||||
for m in more {
|
||||
if !warnings.contains(&m) {
|
||||
warnings.push(m);
|
||||
}
|
||||
}
|
||||
Ok(PrintReport { bytes, pages: pages.len(), warnings })
|
||||
Ok(PrintReport { bytes, pages: plan.order.len(), warnings })
|
||||
}
|
||||
|
||||
/// The printer's marks of page `l` (`None` without marks), with its page information: the
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ mod tests_pdfoutput;
|
|||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_recovery;
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"file.print",
|
||||
"Print…",
|
||||
"Cmd+P",
|
||||
"{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}",
|
||||
"{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way",
|
||||
),
|
||||
(
|
||||
"print.printers",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
//! Printing through the platform. The host app installs a [`PrintService`] ([`crate::Services`]):
|
||||
//! the desktop hands the job to the system's print spooler, the web to the browser's print dialog.
|
||||
//! `file.print` with settings sends the engine's print-ready PDF (`file.print` in the engine) to
|
||||
//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service;
|
||||
//! `print.printers` lists the printers.
|
||||
//! it, or saves the PDF when asked to (`toFile`, `path`) or when there is no print service; a
|
||||
//! PostScript file (`format: "postscript"`, or a `.ps` path) is always saved. `print.printers`
|
||||
//! lists the printers.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use crate::{VectorcraftApp, io};
|
||||
|
||||
|
|
@ -50,19 +52,28 @@ pub fn printers(app: &mut VectorcraftApp) -> Value {
|
|||
|
||||
/// `file.print` with settings: print the active document with `settings` (over the document's)
|
||||
/// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked
|
||||
/// file, the web downloads it) with `toFile`, a `path` or no print service.
|
||||
/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file
|
||||
/// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved
|
||||
/// the same way.
|
||||
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some();
|
||||
let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps");
|
||||
let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript;
|
||||
let mut q = json!({});
|
||||
if let Some(set) = p.get("settings").filter(|v| !v.is_null()) {
|
||||
q["settings"] = set.clone();
|
||||
for k in ["settings", "format", "level", "flattenerPreset"] {
|
||||
if let Some(v) = p.get(k).filter(|v| !v.is_null()) {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
}
|
||||
if postscript {
|
||||
// The path is taken off before the engine runs: it can't tell the format from it.
|
||||
q["format"] = json!("postscript");
|
||||
}
|
||||
if to_file || app.services.print.is_none() {
|
||||
if let Some(path) = s("path") {
|
||||
q["path"] = json!(path);
|
||||
}
|
||||
let (path, mut v) = io::run_to_file(app, "file.print", "pdf", q)?;
|
||||
let (path, mut v) = io::run_to_file(app, "file.print", if postscript { "ps" } else { "pdf" }, q)?;
|
||||
io::report_saved(app, &path, &v);
|
||||
if !to_file {
|
||||
app.ui.status.push_str(" (printing isn't available here: the job was saved as a PDF)");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! Print to a PostScript file through the app: the job is saved, never sent to the printer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::print::{PrintJob, PrintService, Printer};
|
||||
use crate::{Services, VectorcraftApp};
|
||||
|
||||
/// What the app wrote: (path, bytes).
|
||||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// A printer that counts the jobs it is sent.
|
||||
struct Counting(Rc<RefCell<usize>>);
|
||||
|
||||
impl PrintService for Counting {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
vec![Printer { name: "Office".into(), default: true }]
|
||||
}
|
||||
|
||||
fn print(&mut self, _: &PrintJob) -> Result<String, String> {
|
||||
*self.0.borrow_mut() += 1;
|
||||
Ok("Sent".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postscript_is_saved_not_printed() {
|
||||
let sent = Rc::new(RefCell::new(0));
|
||||
let written: Written = Rc::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
print: Some(Box::new(Counting(sent.clone()))),
|
||||
pick_save: Some(Box::new(|pick: &crate::FilePick| Some(format!("/tmp/{}", pick.name)))),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80, "artboards": 2})).unwrap();
|
||||
// A .ps path: PostScript, at the level asked for.
|
||||
let r = app.run("file.print", json!({"path": "/tmp/job.ps", "level": 2})).unwrap();
|
||||
assert_eq!((r["format"].clone(), r["printed"].clone(), r["pages"].clone()), (json!("postscript"), json!(false), json!(2)), "{r}");
|
||||
// No path: a picked .ps file.
|
||||
let r = app.run("file.print", json!({"format": "postscript", "settings": {"copies": 2}})).unwrap();
|
||||
assert_eq!(r["pages"], 4);
|
||||
let w = written.borrow();
|
||||
assert_eq!(w.len(), 2);
|
||||
assert_eq!(w[0].0, "/tmp/job.ps");
|
||||
assert!(w[1].0.ends_with(".ps"), "{}", w[1].0);
|
||||
for (_, bytes) in w.iter() {
|
||||
assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n"));
|
||||
}
|
||||
assert!(String::from_utf8_lossy(&w[0].1).contains("%%LanguageLevel: 2"));
|
||||
assert_eq!(*sent.borrow(), 0, "nothing went to the printer");
|
||||
drop(w);
|
||||
// A PDF still goes to the printer.
|
||||
app.run("file.print", json!({"settings": {"copies": 1}})).unwrap();
|
||||
assert_eq!(*sent.borrow(), 1);
|
||||
assert!(app.run("file.print", json!({"format": "ps2"})).is_err());
|
||||
}
|
||||
39
docs/mcp.md
39
docs/mcp.md
|
|
@ -240,8 +240,8 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
|
|||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}}
|
||||
```
|
||||
|
||||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
|
||||
say so, as does an `.ai` whose PDF part is only a placeholder page.
|
||||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
|
||||
reader (see EPS and PostScript import); an `.ai` whose PDF part is only a placeholder page says it can't be opened.
|
||||
|
||||
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
|
||||
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
|
||||
|
|
@ -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]}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue