From 4c4850e7456875cebf6cf58f8500b81cd7ba1149 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:31:26 +0700 Subject: [PATCH] M4.76: EPS export (PostScript Level 2 and 3) with EPS Options, TIFF previews and the document embedded New crates/eps (L3): a hand-written EPS writer. A DSC header with %%BoundingBox and %%HiResBoundingBox (y up from the origin), %%LanguageLevel and spot colours; paths filled and stroked with caps, joins, miter limits and dashes (strokes a plain line can't draw as filled outlines, brushes as their art), clipping groups, linear and radial gradients as smooth shadings at Level 3 and stepped fills at Level 2 or with compatible gradient printing, patterns as tiles, freeform gradients and placed images as images (Flate with masked transparent pixels at Level 3, run-length at Level 2), type as glyph outlines, spot colours as Separation spaces and overprints when preserved; RGB written as CMYK on request. A TIFF preview (1-bit or colour, transparent or opaque) behind the binary header, a PNG thumbnail and the native document in comments (eps::native and eps::thumbnail read them back for a later import). document.export {format: eps} and document.exportEps: transparency is flattened first with a flattener preset (built-in or saved) and options; the visible art's bounds, or with useArtboards one file per artboard named {stem}_{artboard}.eps. Export As lists EPS, then EPS Options (ui.epsOptionsDialog for agents, remembered between exports). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 18 + Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/fileio/encode.rs | 9 +- crates/engine/src/cmd/fileio/eps.rs | 310 +++++++ crates/engine/src/cmd/fileio/export.rs | 13 +- crates/engine/src/cmd/fileio/mod.rs | 20 +- crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_eps.rs | 205 +++++ crates/engine/src/cmd/flatten.rs | 22 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/tests/import_fuzz.rs | 28 + crates/eps/Cargo.toml | 23 + crates/eps/src/lib.rs | 374 +++++++++ crates/eps/src/ps.rs | 268 ++++++ crates/eps/src/scene.rs | 941 ++++++++++++++++++++++ crates/eps/src/tests.rs | 466 +++++++++++ crates/eps/src/tiff.rs | 146 ++++ crates/mcp/src/tests_fileio.rs | 5 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs/eps_options.rs | 129 +++ crates/ui-egui/src/dialogs/export_as.rs | 10 +- crates/ui-egui/src/dialogs/mod.rs | 5 + crates/ui-egui/src/dialogs/tests_eps.rs | 75 ++ crates/ui-egui/src/menus.rs | 12 + crates/ui-egui/src/state.rs | 4 + docs/control-protocol.md | 8 + docs/mcp.md | 25 + xtask/src/layers.rs | 1 + 30 files changed, 3113 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/eps.rs create mode 100644 crates/engine/src/cmd/fileio/tests_eps.rs create mode 100644 crates/eps/Cargo.toml create mode 100644 crates/eps/src/lib.rs create mode 100644 crates/eps/src/ps.rs create mode 100644 crates/eps/src/scene.rs create mode 100644 crates/eps/src/tests.rs create mode 100644 crates/eps/src/tiff.rs create mode 100644 crates/ui-egui/src/dialogs/eps_options.rs create mode 100644 crates/ui-egui/src/dialogs/tests_eps.rs diff --git a/Cargo.lock b/Cargo.lock index 6e7e9cbd..cbfed934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4176,6 +4176,7 @@ dependencies = [ "vectorcraft-cad", "vectorcraft-color", "vectorcraft-doc", + "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", "vectorcraft-pathops", @@ -4188,6 +4189,22 @@ dependencies = [ "vectorcraft-trace", ] +[[package]] +name = "vectorcraft-eps" +version = "0.2.0" +dependencies = [ + "flate2", + "image", + "kurbo 0.13.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-testkit", + "vectorcraft-text", +] + [[package]] name = "vectorcraft-format" version = "0.2.0" @@ -4387,6 +4404,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-effects", "vectorcraft-engine", + "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", "vectorcraft-pdf", diff --git a/Cargo.toml b/Cargo.toml index e7d14681..f4252d96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ vectorcraft-ui-egui = { path = "crates/ui-egui" } vectorcraft-mcp = { path = "crates/mcp" } vectorcraft-testkit = { path = "crates/testkit" } vectorcraft-cad = { path = "crates/cad" } +vectorcraft-eps = { path = "crates/eps" } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 98cdb135..0222560f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ vectorcraft-svg = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } +vectorcraft-eps = { workspace = true } image = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index a94e65dc..70029d84 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -74,6 +74,9 @@ pub struct Encoded { pub maps: Vec, /// The encoder's warnings (features approximated or left out, options not applied yet). pub warnings: Vec, + /// What joins the file's stem and the artboard name when there is a file per artboard + /// (default `-`; EPS uses `_`, as the reference app names them). + pub joiner: Option<&'static str>, } impl Encoded { @@ -98,7 +101,9 @@ impl Encoded { super::artboard_file_names(doc, &boards) .into_iter() .zip(files) - .map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice()))) + .map(|(board, (_, bytes))| { + (sibling(&format!("{stem}{}{board}{ext}", self.joiner.unwrap_or("-"))), Cow::Borrowed(bytes.as_slice())) + }) .collect() } }; @@ -313,6 +318,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "dxf" => return super::dxf::encode(doc, p, use_artboards), + // EPS reads its own `useArtboards` (the art's bounds unless asked). + "eps" => return super::eps::encode(doc, p), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs new file mode 100644 index 00000000..a732a71c --- /dev/null +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -0,0 +1,310 @@ +//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As +//! → EPS Options): the options parsed from the params, transparency flattened with a flattener +//! preset, one file of the art's bounds or one per artboard, the preview and thumbnail rendered, +//! and the native document embedded for reopening. + +use std::borrow::Cow; + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster}; +use vectorcraft_geom::{Point, Rect}; + +use super::super::flatten::{FlattenOptions, flatten_document}; +use super::super::*; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +/// Most pixels along a side of a preview. +const MAX_PREVIEW: f64 = 2048.0; +/// Most pixels along a side of a thumbnail. +const THUMBNAIL: f64 = 128.0; + +pub fn specs() -> Vec { + vec![cmd!( + "document.exportEps", + "Export EPS", + [], + None, + "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", + has_doc, + export_eps + )] +} + +/// The EPS options `document.formats` lists. +pub(super) const OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::RANGE, + FormatOption { + name: "useArtboards", + ty: "boolean", + default: "null", + description: "true: one file per chosen artboard (default all), _.eps; false or absent: one file of the visible art (naming artboards also uses them)", + }, + FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, + FormatOption { + name: "previewFormat", + ty: "string", + default: "\"tiffColor\"", + description: "none, tiffBw (1 bit) or tiffColor: a TIFF preview other apps show", + }, + FormatOption { + name: "transparentPreview", + ty: "boolean", + default: "true", + description: "the colour preview keeps transparency (false: on white)", + }, + FormatOption { name: "overprints", ty: "string", default: "\"preserve\"", description: "preserve or discard overprinting fills and strokes" }, + FormatOption { + name: "flattenerPreset", + ty: "string", + default: "\"medium\"", + description: "the flattener preset transparency is flattened with: high, medium, low or a saved one", + }, + FormatOption { + name: "flattener", + ty: "object", + default: "null", + description: "flattener options over the preset (as object.flattenTransparency)", + }, + FormatOption { + name: "embedFonts", + ty: "boolean", + default: "true", + description: "type is written as outlines, so the file needs no fonts either way", + }, + FormatOption { + name: "includeLinkedFiles", + ty: "boolean", + default: "false", + description: "the embedded document keeps linked images whole (else their previews)", + }, + FormatOption { name: "thumbnails", ty: "boolean", default: "true", description: "a small PNG thumbnail in the file" }, + FormatOption { + name: "cmykPostScript", + ty: "boolean", + default: "true", + description: "RGB documents are written in CMYK, for devices without RGB", + }, + FormatOption { name: "compatibleGradients", ty: "boolean", default: "false", description: "gradients as stepped fills at level 3 too" }, + FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" }, +]; + +/// The EPS params, as they come (each checked when read). +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct Params { + #[serde(flatten)] + boards: ArtboardPick, + use_artboards: Option, + level: Option, + preview_format: Option, + transparent_preview: Option, + overprints: Option, + flattener_preset: Option, + flattener: Option, + include_linked_files: Option, + thumbnails: Option, + cmyk_post_script: Option, + compatible_gradients: Option, +} + +/// The settings of one EPS export. +struct Settings { + /// The writer's options (the region, origin and native document are set per file). + eps: EpsOptions, + flatten: FlattenOptions, + include_linked: bool, + thumbnails: bool, + boards: ArtboardPick, + use_artboards: Option, +} + +/// An option read by `read`, or an error listing what it may be. +fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, default: T, allowed: &str) -> Result { + match v { + None => Ok(default), + Some(s) => read(&s).ok_or_else(|| bad(C, format!("EPS {field} `{s}`: {allowed}"))), + } +} + +/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in +/// presets and `saved` ones; discarded overprints are dropped from the flattened art too. +fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result { + let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) }); + if let Some(name) = &p.flattener_preset { + q["preset"] = json!(name); + } + FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}"))) +} + +fn params(p: &Value) -> Result { + if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("EPS options: {e}"))) } else { Ok(Params::default()) } +} + +fn parse(p: &Value) -> Result { + let q = params(p)?; + let d = EpsOptions::default(); + 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 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")?, + transparent_preview: q.transparent_preview.unwrap_or(true), + overprint, + cmyk: q.cmyk_post_script.unwrap_or(true), + compatible_gradients: q.compatible_gradients.unwrap_or(false), + ..d + }; + Ok(Settings { + eps, + flatten, + include_linked: q.include_linked_files.unwrap_or(false), + thumbnails: q.thumbnails.unwrap_or(true), + boards: q.boards, + use_artboards: q.use_artboards, + }) +} + +/// `p` checked (dialogs keep themselves open on an error) and with a saved flattener preset of +/// `saved` it names written out, as the encoder needs it. +pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result { + let run = || -> Result { + let q = expanded(p, saved)?.unwrap_or_else(|| p.clone()); + parse(&q)?; + Ok(q) + }; + run().map_err(|e| e.to_string()) +} + +/// `p` with a saved flattener preset it names written out as `flattener` options, for the +/// encoder, which knows only the built-in presets; `None` when `p` names none. +fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result> { + match str_param(p, "flattenerPreset") { + Some(name) if FlattenOptions::preset(name).is_none() => { + let o = flattener(¶ms(p)?, saved)?; + let mut q = p.clone(); + q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?; + if let Some(m) = q.as_object_mut() { + m.remove("flattenerPreset"); + } + Ok(Some(q)) + } + _ => Ok(None), + } +} + +/// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner +/// as the origin, or the visible art's bounds with the first artboard's corner. +fn pages(doc: &Document, set: &Settings) -> Result, Rect, Point)>> { + let n = doc.artboards.len(); + let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; + let boards = match (set.use_artboards, named) { + (Some(false), _) => None, + (_, Some(b)) => Some(b), + (Some(true), None) => Some((0..n).collect()), + (None, None) => None, + }; + let corner = |r: Rect| Point::new(r.x0, r.y1); + match boards { + Some(boards) => boards + .into_iter() + .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))) + .collect(), + None => { + let art = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; + Ok(vec![(None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)))]) + } + } +} + +/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA, +/// transparent or on white. +fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result { + let long = rect.width().max(rect.height()); + let scale = if long > max { max / long } else { 1.0 }; + vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?; + let img = r.render_region(doc, rect, scale, !transparent); + Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() }) +} + +/// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of +/// several artboards), with linked images whole when `include_linked`. +fn native(doc: &Document, board: Option, include_linked: bool) -> Vec { + let several = board.filter(|_| doc.artboards.len() > 1); + if several.is_none() && !include_linked { + return vectorcraft_format::save(doc, false); + } + let mut d = doc.clone(); + if let Some(a) = several.and_then(|b| doc.artboards.get(b)) { + d.artboards = vec![a.clone()]; + } + if include_linked { + // A blob keeps its preview only while its file hasn't been read. + for blob in d.images.values_mut().filter(|b| !b.is_proxy()) { + blob.proxy = None; + } + } + vectorcraft_format::save(&d, false) +} + +/// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen +/// artboard. Transparency is flattened first (once for all of them); the previews show `doc` +/// itself. +pub(super) fn encode(doc: &Document, p: &Value) -> Result { + let set = parse(p)?; + let pages = pages(doc, &set)?; + if pages.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + let transparent = doc.layers.iter().any(|l| l.shows_transparency()); + let flat = flatten_document(doc, &set.flatten)?; + let drawn = flat.as_ref().unwrap_or(doc); + let mut enc = Encoded { joiner: Some("_"), ..Encoded::default() }; + if transparent && flat.is_some() { + enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); + } + let created = vectorcraft_doc::metadata::now_unix(); + let mut renderer = vectorcraft_render::Renderer::new(); + for (board, region, origin) in pages { + let rect = vectorcraft_eps::preview_rect(region, origin); + let preview = match set.eps.preview { + Preview::None => None, + kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?), + }; + let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; + let o = + EpsOptions { region, origin, title: doc.title.clone(), created, native: Some(native(doc, board, set.include_linked)), ..set.eps.clone() }; + let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; + enc.files.push((board, out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + } + Ok(enc) +} + +fn export_eps(s: &mut Session, p: &Value) -> Result { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["format"] = json!("eps"); + super::export::export(s, &q) +} + +/// `p` with a saved flattener preset of the session's written out (see [`expanded`]). +pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result> { + Ok(match expanded(&p, &s.prefs.flattener_presets)? { + Some(q) => Cow::Owned(q), + None => p, + }) +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 2110d22e..c9b7716f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -15,7 +15,7 @@ use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, wr pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; - let expanded = super::pdf::expand_preset(s, "document.serialize", p)?; + let expanded = expand_presets(s, "document.serialize", p)?; let p = &*expanded; let doc = &*source(s, f, p, "document.serialize")?; let enc = encode_all(doc, f.id, p)?; @@ -46,7 +46,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { pub(super) fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path"); let f = writable("document.export", str_param(p, "format"), path)?; - let expanded = super::pdf::expand_preset(s, "document.export", p)?; + let expanded = expand_presets(s, "document.export", p)?; let p = &*expanded; let doc = &*source(s, f, p, "document.export")?; let enc = encode_all(doc, f.id, p)?; @@ -101,6 +101,13 @@ pub(super) fn without_artboards(p: &Value) -> Value { q } +/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders, +/// which know only the built-in ones. +fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result> { + let pdf = super::pdf::expand_preset(s, cmd, p)?; + super::eps::with_flattener(s, pdf) +} + /// The document an export of `f` writes: the active one (see [`export_source`]), or for text /// with `selectionOnly` the selected objects alone. fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result> { @@ -135,7 +142,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportSelection"; let path = str_param(p, "path"); let f = writable(C, str_param(p, "format"), path)?; - let expanded = super::pdf::expand_preset(s, C, p)?; + let expanded = expand_presets(s, C, p)?; let p = &*expanded; let (d, bounds) = selection(s, C)?; let bytes = encode(&d, f.id, &without_artboards(p))?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 82383ca0..4a72a82a 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -12,6 +12,7 @@ //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. //! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). +//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -19,6 +20,7 @@ mod batch; pub mod dxf; mod encode; +pub mod eps; mod export; mod imagemap; mod load; @@ -75,7 +77,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -84,7 +86,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -420,6 +422,16 @@ pub const FORMATS: &[Format] = &[ Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS }, Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS }, Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: false, write: true, raster: false, options: dxf::OPTIONS }, + Format { + id: "eps", + label: "EPS", + extensions: &["eps"], + mime: "application/postscript", + read: false, + write: true, + raster: false, + options: eps::OPTIONS, + }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -697,7 +709,11 @@ mod tests_text; #[cfg(test)] mod tests_dxf; + #[cfg(test)] mod tests_svgenc; #[cfg(test)] mod tests_svgimport; + +#[cfg(test)] +mod tests_eps; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index eaff1a8a..11597a8d 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -160,7 +160,7 @@ impl Row { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf") { + if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps") { return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf")); } // Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5f35f34c..fefb621c 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); - assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]); + assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps"]); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs new file mode 100644 index 00000000..0817aa9a --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -0,0 +1,205 @@ +//! EPS export through the commands: the art's bounds or one file per artboard, flattened +//! transparency, previews, colours, options and the document the file carries. + +use serde_json::{Value, json}; + +use super::*; + +fn session(width: f64, height: f64, artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// The PostScript of an EPS file (behind its preview header, if any). +fn postscript(bytes: &[u8]) -> String { + String::from_utf8(vectorcraft_eps::sections(bytes).unwrap().0.to_vec()).unwrap() +} + +fn dsc(ps: &str, key: &str) -> Option { + let prefix = format!("%%{key}:"); + ps.lines().find_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()) +} + +/// The first value of TIFF tag `tag` (a short or a long, in the first directory). +fn tiff_tag(tiff: &[u8], tag: u16) -> Option { + let u16_at = |at: usize| Some(u16::from_le_bytes(tiff.get(at..at + 2)?.try_into().ok()?)); + let dir = u32::from_le_bytes(tiff.get(4..8)?.try_into().ok()?) as usize; + let entry = (0..u16_at(dir)? as usize).map(|i| dir + 2 + i * 12).find(|e| u16_at(*e) == Some(tag))?; + match u16_at(entry + 2)? { + 3 => u16_at(entry + 8).map(u32::from), + _ => Some(u32::from_le_bytes(tiff.get(entry + 8..entry + 12)?.try_into().ok()?)), + } +} + +fn warnings(r: &Value) -> Vec { + r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect() +} + +#[test] +fn eps_covers_the_art_y_up_from_the_first_artboard_with_a_preview_and_the_document() { + let mut s = session(200.0, 200.0, 1); + s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 100, "height": 40})).unwrap(); + let r = s.execute("document.export", &json!({"format": "eps"})).unwrap(); + assert_eq!(r["format"], "eps"); + let bytes = b64(&r); + // The default TIFF preview sits behind the binary header. + let (_, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); + let preview = image::load_from_memory_with_format(tiff.unwrap(), image::ImageFormat::Tiff).unwrap(); + assert_eq!((preview.width(), preview.height()), (100, 40)); + let ps = postscript(&bytes); + assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("50 100 150 140"), "60..100 down is 100..140 up"); + assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("3")); + assert!(ps.contains("[1 0 0 -1 0 200] cm")); + // RGB documents are written in CMYK by default. + assert!(!ps.contains(" rg\n") && ps.contains(" k\n"), "{ps}"); + // The document comes back whole, and so does a thumbnail. + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.layers[0].children().unwrap().len(), 1); + assert!(vectorcraft_eps::thumbnail(&bytes).unwrap().starts_with(b"\x89PNG")); + // Without the preview, thumbnail and CMYK: plain PostScript in RGB. + let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false, "level": 2})).unwrap(); + let bytes = b64(&r); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); + let ps = postscript(&bytes); + assert!(ps.contains(" rg\n") && vectorcraft_eps::thumbnail(&bytes).is_none()); + assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("2")); +} + +#[test] +fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() { + let mut s = session(100.0, 100.0, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap(); + let files = r["files"].as_array().unwrap(); + let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]); + for f in files { + let bytes = b64(f); + let ps = postscript(&bytes); + assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100")); + // Each carries the document with its own artboard. + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.len(), 1); + } + // A range names the artboards (useArtboards implied); one file keeps the path's name. + let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap(); + assert!(r.get("files").is_none()); + assert!(postscript(&b64(&r)).contains(&format!("[1 0 0 -1 {} 100] cm", -second))); + assert!(s.execute("document.exportEps", &json!({"artboard": 5})).is_err()); + // Without art there is nothing to bound. + let mut empty = session(100.0, 100.0, 1); + assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export")); +} + +#[test] +fn transparency_is_flattened_with_the_flattener_preset() { + let mut s = session(200.0, 200.0, 1); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 60, "width": 100, "height": 100})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let opts = json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false}); + let r = s.execute("document.exportEps", &opts).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("flattened")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("written opaque")), "nothing transparent reaches the writer: {w:?}"); + let ps = postscript(&b64(&r)); + // Flat regions: red alone, the half-transparent blue over red, and over white. + let colours: Vec<&str> = ps.lines().filter(|l| l.ends_with(" rg")).collect(); + assert_eq!(colours, ["0.5 0.5 1 rg", "1 0 0 rg", "0.5 0 0.5 rg"], "{ps}"); + // The document is left as it was, and reopens with its transparency. + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.layers[0].children().unwrap().len(), 2); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&b64(&r)).unwrap()).unwrap(); + assert!(native.layers[0].children().unwrap().iter().any(|c| c.opacity < 1.0)); + // A saved preset by name; an unknown one is refused. + s.execute("flattener.presets.save", &json!({"name": "All Raster", "balance": 0})).unwrap(); + let r = s.execute("document.exportEps", &json!({"flattenerPreset": "All Raster", "previewFormat": "none"})).unwrap(); + assert!(postscript(&b64(&r)).contains("image"), "rasterized"); + let e = s.execute("document.exportEps", &json!({"flattenerPreset": "Nope"})).unwrap_err().to_string(); + assert!(e.contains("preset"), "{e}"); +} + +#[test] +fn overprints_are_kept_or_discarded() { + let mut s = session(100.0, 100.0, 1); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + s.execute("attributes.set", &json!({"ids": [id], "overprintFill": true})).unwrap(); + let kept = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap())); + assert!(kept.contains("true op\n"), "{kept}"); + let dropped = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "overprints": "discard"})).unwrap())); + assert!(!dropped.contains("true op")); +} + +#[test] +fn placed_images_are_written_and_previews_are_black_and_white_on_request() { + let mut s = super::tests_svg::image_session(); + let r = s.execute("document.exportEps", &json!({"previewFormat": "tiffBw", "level": 2})).unwrap(); + let bytes = b64(&r); + let ps = postscript(&bytes); + assert!(ps.contains("/RunLengthDecode filter >> image"), "{ps}"); + let tiff = vectorcraft_eps::sections(&bytes).unwrap().1.unwrap(); + // A bilevel TIFF: one sample of one bit, white is zero. + assert!(tiff.starts_with(b"II*\0")); + assert_eq!((tiff_tag(tiff, 258), tiff_tag(tiff, 277), tiff_tag(tiff, 262)), (Some(1), Some(1), Some(0))); + let l3 = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap())); + assert!(l3.contains("/FlateDecode filter >> image")); +} + +#[test] +fn bad_eps_options_are_refused_and_formats_list_eps() { + let mut s = session(100.0, 100.0, 1); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + for p in [ + json!({"level": 1}), + json!({"level": "4"}), + json!({"previewFormat": "pict"}), + json!({"overprints": "simulate"}), + json!({"flattener": {"balance": 400}}), + json!({"thumbnails": "yes"}), + ] { + let e = s.execute("document.exportEps", &p).unwrap_err().to_string(); + assert!(e.contains("EPS"), "{p}: {e}"); + } + 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"))); + for o in [ + "level", + "previewFormat", + "transparentPreview", + "overprints", + "flattenerPreset", + "flattener", + "embedFonts", + "includeLinkedFiles", + "thumbnails", + "cmykPostScript", + "compatibleGradients", + "selectedOnly", + "useArtboards", + ] { + assert!(eps["options"].get(o).is_some(), "{o}"); + } +} + +#[test] +fn selected_only_writes_the_selection() { + let mut s = session(200.0, 200.0, 1); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let ps = postscript(&b64(&s.execute("document.exportEps", &json!({"selectedOnly": true, "previewFormat": "none"})).unwrap())); + assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("100 80 150 100")); +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index e55b54c8..3f096e00 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -983,6 +983,28 @@ fn opaque(n: &mut Node) { } } +/// `doc` with everything it draws flattened with `o`, as formats without transparency (EPS) +/// write it: every visible object of the visible layers, locked ones too (a clipping layer's +/// clipping path stays). `None` when that changes nothing. +pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result> { + let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints; + if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) { + return Ok(None); + } + let mut roots = vec![]; + for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) { + let children = l.children().map_or(&[][..], Vec::as_slice); + roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible).map(|c| c.id)); + } + let plan = plan(doc, &roots, o, true)?; + if plan.flat.is_empty() && !plan.kept.iter().any(|id| doc.node(*id).is_some_and(|n| kept_work(n, o))) { + return Ok(None); + } + let mut d = doc.clone(); + apply(&mut d, &mut vectorcraft_doc::Selection::default(), plan, o)?; + Ok(Some(d)) +} + // ---------- apply ---------- fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec, usize, usize)> { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index aae1919b..97ab9a1d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -225,6 +225,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(links::specs()); v.extend(pdfcmds::specs()); v.extend(fileio::dxf::specs()); + v.extend(fileio::eps::specs()); v.extend(package::specs()); v.extend(slices::specs()); v diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 98860b60..446590c2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -827,3 +827,31 @@ proptest! { survive("mutated native file", || vectorcraft_engine::cmd::fileio::load("x.vectorcraft", &bytes).ok().map(|l| l.doc))?; } } + +/// The rich document as EPS (with a preview, a thumbnail and the document it carries), and the +/// document read back from it. +fn eps_sample() -> Vec { + let mut s = rich_session(); + let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": false})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() +} + +proptest! { + #![proptest_config(config())] + + /// Damaged EPS files: their sections, embedded document and thumbnail read as nothing, or as + /// a document that renders and exports. + #[test] + fn eps_mutated_files_never_panic(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::()), 0..12)) { + static SAMPLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let mut bytes = SAMPLE.get_or_init(eps_sample).clone(); + for &(at, b) in &edits { + let n = bytes.len(); + bytes[at % n] = b; + } + bytes.truncate(cut.max(4)); + let _ = vectorcraft_eps::sections(&bytes); + let _ = vectorcraft_eps::thumbnail(&bytes); + survive("mutated EPS file", || vectorcraft_eps::native(&bytes).and_then(|n| vectorcraft_format::load(&n).ok()))?; + } +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml new file mode 100644 index 00000000..3f7ae721 --- /dev/null +++ b/crates/eps/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vectorcraft-eps" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "VectorCraft Encapsulated PostScript: a hand-written EPS writer (PostScript Level 2 and 3) with TIFF previews" + +[lints] +workspace = true + +[dependencies] +vectorcraft-geom = { workspace = true } +vectorcraft-color = { workspace = true } +vectorcraft-doc = { workspace = true } +vectorcraft-effects = { workspace = true } +vectorcraft-brush = { workspace = true } +vectorcraft-text = { workspace = true } +kurbo = { workspace = true } +image = { workspace = true } +flate2 = { workspace = true } + +[dev-dependencies] +vectorcraft-testkit = { workspace = true } diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs new file mode 100644 index 00000000..fdf9b21e --- /dev/null +++ b/crates/eps/src/lib.rs @@ -0,0 +1,374 @@ +//! Encapsulated PostScript: a hand-written EPS writer, PostScript Level 2 and 3. +//! +//! [`export`] writes the visible art of a document as one EPS file: a DSC header with the +//! `%%BoundingBox` and `%%HiResBoundingBox` of [`EpsOptions::region`] (y up, from +//! [`EpsOptions::origin`]), paths filled and stroked with their caps, joins and dashes (strokes +//! a plain line can't draw as their filled outlines, brushed strokes as their brush art), clipping +//! groups, linear and radial gradients (smooth shadings at Level 3, stepped fills at Level 2 or +//! with compatible gradient printing), pattern fills as their tiles, freeform gradients and placed +//! images as images, type as glyph outlines, spot colours as Separation colour spaces and +//! overprints when preserved. RGB colours are written as CMYK in CMYK documents and, with +//! [`EpsOptions::cmyk`], in RGB ones. +//! +//! PostScript has no transparency: callers flatten it first. What is left (opacity, blending +//! modes, opacity masks, raster effects) is written opaque, and each such loss comes back as a +//! warning. +//! +//! The file can carry a TIFF preview ([`Preview`]) behind a binary header, a PNG thumbnail and the +//! native document, both in comments that PostScript interpreters skip ([`native`], +//! [`thumbnail`] read them back). +//! +//! - `ps`: numbers, strings, paths and the data encodings (ASCII85, run-length, Flate). +//! - `scene`: the document walk that writes the page. +//! - `tiff`: the TIFF preview. + +mod ps; +mod scene; +mod tiff; + +use vectorcraft_doc::Document; +use vectorcraft_geom::{Point, Rect}; + +/// The PostScript language level the file needs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + /// Gradients as stepped fills, images run-length encoded, image transparency written white. + Two, + /// Smooth shadings, Flate-compressed images, transparent image pixels masked out. + #[default] + Three, +} + +impl Level { + pub const ALL: [Self; 2] = [Self::Two, Self::Three]; + + /// The `level` param value. + pub fn id(self) -> &'static str { + match self { + Self::Two => "2", + Self::Three => "3", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Two => "Language Level 2", + Self::Three => "Language Level 3", + } + } + + /// A level by id (`2`, `3`, `level2`, `LanguageLevel 3`), any case. + pub fn from_id(s: &str) -> Option { + let digits: String = s.chars().filter(char::is_ascii_digit).collect(); + Self::ALL.into_iter().find(|l| l.id() == digits) + } +} + +/// The preview other apps show for the file: none, or a TIFF image behind a binary header. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Preview { + None, + /// One bit per pixel: black where the art is darker than mid-grey. + TiffBlackWhite, + /// 8 bits per channel, on white or with transparency ([`EpsOptions::transparent_preview`]). + #[default] + TiffColor, +} + +impl Preview { + pub const ALL: [Self; 3] = [Self::None, Self::TiffBlackWhite, Self::TiffColor]; + + /// The `preview` param value. + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::TiffBlackWhite => "tiffBw", + Self::TiffColor => "tiffColor", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::TiffBlackWhite => "TIFF (Black & White)", + Self::TiffColor => "TIFF (8-bit Color)", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id())) + } +} + +/// What happens to overprinting fills and strokes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Overprint { + /// They overprint on press (`setoverprint`). + #[default] + Preserve, + /// They knock out like any other art. + Discard, +} + +impl Overprint { + pub const ALL: [Self; 2] = [Self::Preserve, Self::Discard]; + + /// The `overprints` param value. + pub fn id(self) -> &'static str { + match self { + Self::Preserve => "preserve", + Self::Discard => "discard", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Preserve => "Preserve", + Self::Discard => "Discard", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|o| s.trim().eq_ignore_ascii_case(o.id())) + } +} + +/// An image the caller rendered: straight (not premultiplied) RGBA, row by row from the top. +#[derive(Clone, Debug, Default)] +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The EPS export settings. +#[derive(Clone, Debug, Default)] +pub struct EpsOptions { + pub level: Level, + pub preview: Preview, + /// A colour preview keeps the art's transparency (else it is on white). + pub transparent_preview: bool, + pub overprint: Overprint, + /// Write RGB colours as CMYK (CMYK documents always are). + pub cmyk: bool, + /// Gradients as stepped fills at Level 3 too (for devices without smooth shading). + pub compatible_gradients: bool, + /// The area of the document the bounding box covers (document space, y down). + pub region: Rect, + /// The document point PostScript's origin is at (y up from it). + pub origin: Point, + /// `%%Title`. + pub title: String, + /// `%%CreationDate` as Unix seconds (UTC); `None`: left out. + pub created: Option, + /// The native document the file carries, for reopening it as it was. + pub native: Option>, +} + +/// A written file. +#[derive(Clone, Debug, Default)] +pub struct EpsOutput { + pub bytes: Vec, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole +/// points enclosing it, y up (`[llx, lly, urx, ury]`). +pub fn bounding_box(region: Rect, origin: Point) -> [i64; 4] { + let [x0, y0, x1, y1] = hires_box(region, origin); + [x0.floor() as i64, y0.floor() as i64, x1.ceil() as i64, y1.ceil() as i64] +} + +/// The `%%HiResBoundingBox` of `region` (see [`bounding_box`]). +fn hires_box(region: Rect, origin: Point) -> [f64; 4] { + let r = region.abs(); + [r.x0 - origin.x, origin.y - r.y1, r.x1 - origin.x, origin.y - r.y0] +} + +/// The document rect a preview must show: the whole-point [`bounding_box`], which it covers. +pub fn preview_rect(region: Rect, origin: Point) -> Rect { + let [x0, y0, x1, y1] = bounding_box(region, origin).map(|v| v as f64); + Rect::new(origin.x + x0, origin.y - y1, origin.x + x1, origin.y - y0) +} + +/// Write `doc` as EPS with `opts`. `preview` is the art of [`preview_rect`] when +/// [`EpsOptions::preview`] asks for one, and `thumbnail` a small image of it to embed. Live +/// geometry effects are applied first; template layers, guides and hidden objects are left out. +pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumbnail: Option<&Raster>) -> Result { + let r = opts.region; + if ![r.x0, r.y0, r.x1, r.y1, opts.origin.x, opts.origin.y].iter().all(|v| v.is_finite()) { + return Err("the export region is not finite".into()); + } + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + let page = scene::Scene::new(doc, opts).run(); + let mut warnings = page.warnings; + let thumb = thumbnail.and_then(|t| match tiff::png(t) { + Ok(png) => Some(png), + Err(e) => { + warnings.push(format!("the thumbnail was left out: {e}")); + None + } + }); + let ps = document(opts, &page.setup, &page.body, &page.custom, thumb.as_deref()); + let bytes = match (opts.preview, preview) { + (Preview::None, _) => ps.into_bytes(), + (_, None) => { + warnings.push("the preview was left out: nothing was rendered for it".into()); + ps.into_bytes() + } + (kind, Some(img)) => { + let tiff = tiff::encode(img, kind == Preview::TiffBlackWhite, opts.transparent_preview && kind == Preview::TiffColor)?; + dos_eps(ps.as_bytes(), &tiff)? + } + }; + Ok(EpsOutput { bytes, warnings }) +} + +/// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: `) +/// and end it; between them, ASCII85 lines after `% `. +const BEGIN_DATA: &str = "%VectorCraft_BeginData: "; +const END_DATA: &str = "%VectorCraft_EndData"; +/// The data sections. +const NATIVE: &str = "native"; +const THUMBNAIL: &str = "thumbnail"; +/// Largest native document [`native`] inflates (bytes). +const MAX_NATIVE: u64 = 1 << 30; + +/// 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 { + use std::fmt::Write; + let mut s = String::with_capacity(body.len() + setup.len() + 4096); + 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)"); + } + 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); + } + s.push_str("%%BeginProlog\n"); + s.push_str(ps::PROLOG); + s.push_str("%%EndProlog\n%%BeginSetup\n"); + if !setup.is_empty() { + s.push_str("VCdict begin\n"); + s.push_str(setup); + s.push_str("end\n"); + } + s.push_str("%%EndSetup\n%%Page: 1 1\nVCdict begin\nq\n"); + // Document space (y down) onto the page (y up from the origin). + let _ = writeln!(s, "[1 0 0 -1 {} {}] cm", ps::num(-o.origin.x), ps::num(o.origin.y)); + s.push_str(body); + s.push_str("Q\nend\nshowpage\n%%PageTrailer\n%%Trailer\n"); + if let Some(native) = o.native.as_deref() { + data_section(&mut s, NATIVE, &ps::deflate(native)); + } + s.push_str("%%EOF\n"); + s +} + +/// A data section of comment lines carrying `bytes` (see [`BEGIN_DATA`]). +fn data_section(s: &mut String, name: &str, bytes: &[u8]) { + s.push_str(BEGIN_DATA); + s.push_str(name); + s.push('\n'); + for line in ps::ascii85(bytes).lines() { + s.push_str("% "); + s.push_str(line); + s.push('\n'); + } + s.push_str(END_DATA); + s.push('\n'); +} + +/// The bytes of data section `name` in the PostScript `ps`, when it has one that decodes. +fn read_data(ps: &[u8], name: &str) -> Option> { + let text = String::from_utf8_lossy(ps); + let mut lines = text.lines(); + lines.find(|l| l.strip_prefix(BEGIN_DATA).is_some_and(|n| n.trim() == name))?; + let mut encoded = String::new(); + for l in lines { + if l.starts_with(END_DATA) { + return ps::ascii85_decode(&encoded); + } + encoded.push_str(l.strip_prefix('%').unwrap_or(l)); + } + None +} + +/// The magic number of an EPS file with a binary header (a preview). +const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6]; +/// The binary header's size. +const DOS_HEADER: usize = 30; + +/// `ps` behind a binary header, followed by the TIFF preview `tiff`. +fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result, String> { + let big = || "the file is too large for an EPS preview header (4 GB): choose no preview".to_string(); + let ps_len = u32::try_from(ps.len()).map_err(|_| big())?; + let tiff_at = u32::try_from(DOS_HEADER + ps.len()).map_err(|_| big())?; + let tiff_len = u32::try_from(tiff.len()).map_err(|_| big())?; + tiff_at.checked_add(tiff_len).ok_or_else(big)?; + let mut out = Vec::with_capacity(DOS_HEADER + ps.len() + tiff.len()); + out.extend(DOS_MAGIC); + out.extend((DOS_HEADER as u32).to_le_bytes()); + out.extend(ps_len.to_le_bytes()); + // No metafile preview. + out.extend(0u32.to_le_bytes()); + out.extend(0u32.to_le_bytes()); + out.extend(tiff_at.to_le_bytes()); + out.extend(tiff_len.to_le_bytes()); + // No checksum. + out.extend(0xFFFFu16.to_le_bytes()); + out.extend(ps); + out.extend(tiff); + Ok(out) +} + +/// The sections of an EPS file: its PostScript and its TIFF preview (binary header), or the whole +/// file as PostScript. `None` when a binary header points outside the file. +pub fn sections(bytes: &[u8]) -> Option<(&[u8], Option<&[u8]>)> { + if !bytes.starts_with(&DOS_MAGIC) { + return Some((bytes, None)); + } + let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize); + let part = |at: usize| { + let (start, len) = (word(at)?, word(at + 4)?); + bytes.get(start..start.checked_add(len)?) + }; + let ps = part(4)?; + let tiff = match word(24) { + Some(0) | None => None, + Some(_) => Some(part(20)?), + }; + Some((ps, tiff)) +} + +/// The native document an EPS file written by [`export`] carries, if any. +pub fn native(bytes: &[u8]) -> Option> { + let (ps, _) = sections(bytes)?; + ps::inflate(&read_data(ps, NATIVE)?, MAX_NATIVE) +} + +/// The PNG thumbnail an EPS file written by [`export`] carries, if any. +pub fn thumbnail(bytes: &[u8]) -> Option> { + read_data(sections(bytes)?.0, THUMBNAIL) +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs new file mode 100644 index 00000000..f92727fe --- /dev/null +++ b/crates/eps/src/ps.rs @@ -0,0 +1,268 @@ +//! PostScript writing primitives: numbers, strings, paths, the prolog's short operator names, and +//! the data encodings images and embedded data use (ASCII85, run-length, Flate). + +use std::fmt::Write; +use std::io::{Read, Write as _}; + +use kurbo::PathEl; +use vectorcraft_geom::{Affine, BezPath, FillRule}; + +/// The procedures the page uses, in `VCdict`. +pub(crate) const PROLOG: &str = "/VCdict 64 dict def +VCdict begin +/bd {bind def} bind def +/m {moveto} bd /l {lineto} bd /c {curveto} bd /h {closepath} bd +/f {fill} bd /f* {eofill} bd /S {stroke} bd +/W {clip newpath} bd /W* {eoclip newpath} bd +/q {gsave} bd /Q {grestore} bd /cm {concat} bd +/g {setgray} bd /rg {setrgbcolor} bd /k {setcmykcolor} bd +/w {setlinewidth} bd /J {setlinecap} bd /j {setlinejoin} bd /M {setmiterlimit} bd /d {setdash} bd +/op {setoverprint} bd +end +"; + +/// Longest line of encoded data (DSC readers take up to 255 characters). +const LINE: usize = 76; + +/// `v` as PostScript writes it: at most four decimals, no trailing zeros (0 when not finite). +pub(crate) fn num(v: f64) -> String { + let mut s = String::new(); + push_num(&mut s, v); + s +} + +/// Append [`num`]`(v)` to `s`. +pub(crate) fn push_num(s: &mut String, v: f64) { + let v = if v.is_finite() { (v * 10_000.0).round() / 10_000.0 } else { 0.0 }; + if v == v.trunc() && v.abs() < 1e15 { + let _ = write!(s, "{}", v as i64); + return; + } + let start = s.len(); + let _ = write!(s, "{v:.4}"); + let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); + s.truncate(start + keep); +} + +/// `vs` as numbers separated by spaces, then a space. +pub(crate) fn push_nums(s: &mut String, vs: &[f64]) { + for v in vs { + push_num(s, *v); + s.push(' '); + } +} + +/// `text` as a PostScript string: `(` `)` `\` escaped, anything outside printable ASCII as octal +/// escapes of its UTF-8 bytes. +pub(crate) fn string(text: &str) -> String { + let mut s = String::with_capacity(text.len() + 2); + s.push('('); + for b in text.bytes() { + match b { + b'(' | b')' | b'\\' => { + s.push('\\'); + s.push(char::from(b)); + } + 0x20..=0x7e => s.push(char::from(b)), + _ => { + let _ = write!(s, "\\{b:03o}"); + } + } + } + s.push(')'); + s +} + +/// A matrix operand `[a b c d e f]`. +pub(crate) fn matrix(a: Affine) -> String { + let mut s = String::from("["); + push_nums(&mut s, &a.as_coeffs()); + s.pop(); + s.push(']'); + s +} + +/// Append path `bp` (`m`, `l`, `c`, `h`; quadratics raised to cubics), one segment a line. +pub(crate) fn push_path(s: &mut String, bp: &BezPath) { + let mut last = kurbo::Point::ZERO; + let mut start = kurbo::Point::ZERO; + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) => { + push_nums(s, &[p.x, p.y]); + s.push_str("m\n"); + (last, start) = (p, p); + } + PathEl::LineTo(p) => { + push_nums(s, &[p.x, p.y]); + s.push_str("l\n"); + last = p; + } + PathEl::QuadTo(a, p) => { + let c1 = last + (a - last) * (2.0 / 3.0); + let c2 = p + (a - p) * (2.0 / 3.0); + push_nums(s, &[c1.x, c1.y, c2.x, c2.y, p.x, p.y]); + s.push_str("c\n"); + last = p; + } + PathEl::CurveTo(a, b, p) => { + push_nums(s, &[a.x, a.y, b.x, b.y, p.x, p.y]); + s.push_str("c\n"); + last = p; + } + PathEl::ClosePath => { + s.push_str("h\n"); + last = start; + } + } + } +} + +/// The fill operator for `rule`. +pub(crate) fn fill_op(rule: FillRule) -> &'static str { + match rule { + FillRule::NonZero => "f\n", + FillRule::EvenOdd => "f*\n", + } +} + +/// The clip operator for `rule` (it also ends the path). +pub(crate) fn clip_op(rule: FillRule) -> &'static str { + match rule { + FillRule::NonZero => "W\n", + FillRule::EvenOdd => "W*\n", + } +} + +/// `bytes` in ASCII85 with its `~>` end, in lines of about [`LINE`] characters, none starting +/// with `%` (DSC readers would take it for a comment). +pub(crate) fn ascii85(bytes: &[u8]) -> String { + let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8); + let (chunks, rest) = bytes.as_chunks::<4>(); + for c in chunks { + let v = u32::from_be_bytes(*c); + if v == 0 { + raw.push('z'); + } else { + push85(&mut raw, v, 5); + } + } + if !rest.is_empty() { + let mut c = [0u8; 4]; + for (d, s) in c.iter_mut().zip(rest) { + *d = *s; + } + push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1); + } + raw.push_str("~>"); + let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2); + let mut col = 0; + for ch in raw.chars() { + if col >= LINE && ch != '%' { + out.push('\n'); + col = 0; + } + out.push(ch); + col += 1; + } + out.push('\n'); + out +} + +/// The first `n` base-85 digits of `v`. +fn push85(s: &mut String, mut v: u32, n: usize) { + let mut d = [0u8; 5]; + for slot in d.iter_mut().rev() { + *slot = (v % 85) as u8 + b'!'; + v /= 85; + } + for b in d.iter().take(n) { + s.push(char::from(*b)); + } +} + +/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed. +pub(crate) fn ascii85_decode(text: &str) -> Option> { + let mut out = Vec::with_capacity(text.len() * 4 / 5); + let mut group = [0u8; 5]; + let mut n = 0; + let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable(); + while let Some(b) = chars.next() { + match b { + b'~' => { + if chars.next() != Some(b'>') { + return None; + } + if n == 1 { + return None; + } + if n > 1 { + let padded: Vec = group.iter().take(n).copied().chain(std::iter::repeat(b'u')).take(5).collect(); + let v = value85(&padded)?; + out.extend(v.to_be_bytes().iter().take(n - 1)); + } + return Some(out); + } + b'z' if n == 0 => out.extend([0; 4]), + b'!'..=b'u' => { + *group.get_mut(n)? = b; + n += 1; + if n == 5 { + out.extend(value85(&group)?.to_be_bytes()); + n = 0; + } + } + _ => return None, + } + } + None +} + +/// The value of five base-85 digits; `None` past `u32::MAX`. +fn value85(digits: &[u8]) -> Option { + digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?))) +} + +/// `data` run-length encoded (PackBits: what TIFF's compression 32773 and PostScript's +/// `RunLengthDecode` read), without an end marker. +pub(crate) fn packbits(data: &[u8], out: &mut Vec) { + let mut i = 0; + while i < data.len() { + let Some(&b) = data.get(i) else { break }; + let run = data.get(i..).map_or(1, |rest| rest.iter().take(128).take_while(|x| **x == b).count()); + if run >= 3 { + out.push((257 - run) as u8); + out.push(b); + i += run; + continue; + } + // Literal bytes up to the next run of three. + let mut j = i; + while j < data.len() && j - i < 128 { + let three = data.get(j..j + 3).is_some_and(|w| w[0] == w[1] && w[1] == w[2]); + if three { + break; + } + j += 1; + } + let j = j.max(i + 1).min(data.len()); + out.push((j - i - 1) as u8); + out.extend(data.get(i..j).unwrap_or_default()); + i = j; + } +} + +/// `data` compressed for `FlateDecode` (zlib). +pub(crate) fn deflate(data: &[u8]) -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::with_capacity(data.len() / 2), flate2::Compression::default()); + // Writing to a Vec doesn't fail. + let _ = e.write_all(data); + e.finish().unwrap_or_default() +} + +/// zlib `data` inflated, when it is valid and at most `max` bytes. +pub(crate) fn inflate(data: &[u8], max: u64) -> Option> { + let mut out = vec![]; + flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).ok()?; + (out.len() as u64 <= max).then_some(out) +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs new file mode 100644 index 00000000..136c9ec1 --- /dev/null +++ b/crates/eps/src/scene.rs @@ -0,0 +1,941 @@ +//! The document walk: each visible object is written into the page in paint order, in document +//! space (the page's first operator maps it onto PostScript's y-up space). Mirrors the PDF +//! writer's walk, without transparency. + +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::Arc; + +use kurbo::Shape; +use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{ + AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject, +}; +use vectorcraft_effects::stroke::{self, WrittenShape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; + +use crate::ps::{self, clip_op, fill_op, push_nums, push_path}; +use crate::{EpsOptions, Level, Overprint}; + +/// How deep symbols and brush art may nest in one another (deeper art is left out). +const MAX_NEST: u32 = 8; +/// Most pixels along a side of a freeform gradient's image (its colour field is smooth). +const MAX_FIELD_PX: f64 = 512.0; +/// Most bands a stepped gradient is drawn with. +const MAX_STEPS: usize = 1024; +/// Thinnest band of a stepped gradient (points). +const MIN_BAND: f64 = 0.25; +/// Largest image written (pixels). +const MAX_IMAGE_PIXELS: u64 = 1 << 26; + +const TRANSPARENCY: &str = + "transparency (opacity, blending modes, opacity masks, see-through gradients) is written opaque: EPS has none, flatten it first"; +const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out: EPS has no transparency, flatten them first"; +const IMAGE_ALPHA_L2: &str = "transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)"; +const IMAGE_PARTLY: &str = "partly transparent image pixels are written over white"; +const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours"; +const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; + +/// What the walk wrote: definitions for the setup, the page, the spot colours it uses (name and +/// CMYK equivalent) and the warnings. +pub(crate) struct Page { + pub setup: String, + pub body: String, + pub custom: Vec<(String, [f32; 4])>, + pub warnings: Vec, +} + +/// The colour spaces process colours are written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Space { + Gray, + Rgb, + Cmyk, +} + +impl Space { + fn name(self) -> &'static str { + match self { + Space::Gray => "/DeviceGray", + Space::Rgb => "/DeviceRGB", + Space::Cmyk => "/DeviceCMYK", + } + } + fn n(self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb => 3, + Space::Cmyk => 4, + } + } +} + +/// A colour as written: in a process space (its first [`Space::n`] components) or a tint of a +/// spot colour space defined in the setup. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ink { + Process(Space, [f32; 4]), + Spot { space: usize, tint: f32 }, +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// Can `a` be drawn through (a finite, non-flat map)? +fn invertible(a: Affine) -> bool { + let det = a.determinant(); + det.is_finite() && det.abs() > 1e-12 +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +pub(crate) struct Scene<'a> { + doc: &'a Document, + o: &'a EpsOptions, + /// RGB and Lab colours are written as CMYK. + cmyk: bool, + cms: Arc, + body: String, + setup: String, + /// Spot colour spaces defined in the setup (`VCsp`): swatch name and CMYK equivalent. + spots: Vec<(String, [f32; 4])>, + warnings: Vec, + brushes: Option>, + /// Image keys → what draws them (`None`: left out). + images: HashMap>>, + /// Symbols and brush art being written inside one another. + nest: u32, +} + +impl<'a> Scene<'a> { + pub fn new(doc: &'a Document, o: &'a EpsOptions) -> Self { + Self { + doc, + o, + cmyk: o.cmyk || doc.color_mode == ColorMode::Cmyk, + cms: vectorcraft_color::cms::active(), + body: String::new(), + setup: String::new(), + spots: vec![], + warnings: vec![], + brushes: None, + images: HashMap::new(), + nest: 0, + } + } + + pub fn run(mut self) -> Page { + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + 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 } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn line(&mut self, s: &str) { + self.body.push_str(s); + self.body.push('\n'); + } + + // ---------- colour ---------- + + /// A process colour in the space it is written in. + fn process(&self, c: &Color) -> Ink { + match *c { + Color::Cmyk { c, m, y, k } => Ink::Process(Space::Cmyk, [c, m, y, k].map(unit)), + // Grey is ink coverage; PostScript's grey is lightness. + Color::Gray { k } => Ink::Process(Space::Gray, [unit(1.0 - k), 0.0, 0.0, 0.0]), + _ if self.cmyk => Ink::Process(Space::Cmyk, self.cms.to_cmyk(c, self.cms.settings().intent).map(unit)), + Color::Rgb { r, g, b } => Ink::Process(Space::Rgb, [r, g, b, 0.0].map(unit)), + Color::Lab { .. } => { + let [r, g, b] = c.to_rgb(); + Ink::Process(Space::Rgb, [r, g, b, 0.0].map(unit)) + } + } + } + + /// A solid paint: a tint of its spot colour (or of Registration, every plate) when it is linked + /// to one, else a process colour. + fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> Ink { + match link.and_then(|n| self.spot_space(n)) { + Some(space) => Ink::Spot { space, tint: unit(tint) }, + None => self.process(c), + } + } + + /// The index of the Separation colour space of spot swatch (or Registration) `name`, defined + /// in the setup the first time; `None` when `name` is neither. + fn spot_space(&mut self, name: &str) -> Option { + if let Some(i) = self.spots.iter().position(|(n, _)| n == name) { + return Some(i); + } + let registration = name == vectorcraft_color::swatch::REGISTRATION; + let cmyk = if registration { + [1.0; 4] + } else { + let doc = self.doc; + let sw = doc.swatch(name).filter(|s| s.spot)?; + let color = doc.linked_color(sw.paint.color()?, true); + self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit) + }; + let i = self.spots.len(); + let _ = write!(self.setup, "/VCsp{i} [/Separation "); + if registration { + self.setup.push_str("/All /DeviceCMYK {dup dup dup}"); + } else { + let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); + let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(name)); + } + self.setup.push_str("] def\n"); + self.spots.push((name.to_string(), cmyk)); + Some(i) + } + + fn set_ink(&mut self, ink: Ink) { + match ink { + Ink::Process(space, v) => { + for x in v.iter().take(space.n()) { + ps::push_num(&mut self.body, f64::from(*x)); + self.body.push(' '); + } + self.line(match space { + Space::Gray => "g", + Space::Rgb => "rg", + Space::Cmyk => "k", + }); + } + Ink::Spot { space, tint } => { + let _ = writeln!(self.body, "VCsp{space} setcolorspace {} setcolor", ps::num(f64::from(tint))); + } + } + } + + /// Turn overprinting on for one paint (when overprints are preserved); `false` when it stays off. + fn overprint_on(&mut self, overprint: bool) -> bool { + let on = overprint && self.o.overprint == Overprint::Preserve; + if on { + self.line("true op"); + } + on + } + + fn overprint_off(&mut self, on: bool) { + if on { + self.line("false op"); + } + } + + // ---------- objects ---------- + + fn node(&mut self, n: &Node, force: bool) { + if !force && !n.visible { + return; + } + if let NodeKind::Layer { template: true, .. } = n.kind { + return; + } + match n.visual_bounds() { + Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return, + None if !n.is_container() => return, + _ => {} + } + self.note_losses(n); + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + children.iter().for_each(|c| self.node(c, false)); + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => self.clip_group(children), + NodeKind::Path { path, rule, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath(), *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp, *rule); + } + NodeKind::Text(t) => self.text(n, t), + NodeKind::Image(im) => self.image(im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + } + // Live blends, envelopes, meshes and repeats are written as their evaluated art. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + let g = vectorcraft_doc::live::expand_deep(n, None); + for c in g.children().into_iter().flatten() { + self.node(c, false); + } + } + } + } + + /// A clipping group: the art inside its first child's outline. The clipping path's fills + /// paint behind the art (clipped) and its strokes over it (not clipped). + fn clip_group(&mut self, children: &[Arc]) { + let Some((clip, rest)) = children.split_first() else { return }; + // Nothing to clip by hides the clipped art. + let Some((outline, rule)) = vectorcraft_effects::clip_outline(clip) else { return }; + let paint = clip.clip_paint(); + self.line("q"); + push_path(&mut self.body, &outline); + self.body.push_str(clip_op(rule)); + if let Some(fill) = &paint.fill { + self.node(fill, false); + } + rest.iter().for_each(|c| self.node(c, false)); + self.line("Q"); + if let Some(stroke) = &paint.stroke { + self.node(stroke, false); + } + } + + /// Warn about what EPS can't hold in `n`'s look. + fn note_losses(&mut self, n: &Node) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(TRANSPARENCY); + } + let raster = |fx: &[Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn(RASTER_FX); + } + } + + /// Warn when a fill's or stroke's own opacity or blending mode can't be written. + fn note_item(&mut self, opacity: f32, blend: BlendMode) { + if opacity < 1.0 || blend != BlendMode::Normal { + self.warn(TRANSPARENCY); + } + } + + /// A path's fills and strokes, in paint order. + fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) { + if bp.elements().is_empty() { + return; + } + let bounds = bp.bounding_box(); + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(fl) if fl.visible && !fl.paint.is_none() => { + self.note_item(fl.opacity, fl.blend); + self.fill(bp, rule, &fl.paint, bounds, fl.overprint); + } + AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0 && st.width.is_finite() => { + self.stroke(bp, rule, st, bounds); + } + _ => {} + } + } + } + + /// Fill `bp` (by `rule`) with paint `p`; `bounds` places unplaced gradients and patterns. + fn fill(&mut self, bp: &BezPath, rule: FillRule, p: &Paint, bounds: Rect, overprint: bool) { + if p.is_none() { + return; + } + let on = self.overprint_on(overprint); + match p { + Paint::None => {} + Paint::Solid { color, swatch, tint } => { + let ink = self.solid(color, swatch.as_deref(), *tint); + self.set_ink(ink); + push_path(&mut self.body, bp); + self.body.push_str(fill_op(rule)); + } + _ => { + self.line("q"); + push_path(&mut self.body, bp); + self.body.push_str(clip_op(rule)); + self.paint_area(p, bounds, bp.bounding_box()); + self.line("Q"); + } + } + self.overprint_off(on); + } + + /// Paint all of `area` (inside the current clip) with gradient or pattern `p`, placed on + /// `bounds`. + fn paint_area(&mut self, p: &Paint, bounds: Rect, area: Rect) { + let doc = self.doc; + match p { + Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => self.field_image(g, bounds, area), + Paint::Gradient(g) => self.gradient(g, bounds, area), + Paint::Pattern { pattern, xf } => match doc.pattern(pattern) { + Some(def) => { + for inst in def.instances_in(*xf, area) { + self.node(&inst, true); + } + } + None => { + self.warn(MISSING_PATTERN); + self.set_ink(Ink::Process(Space::Gray, [0.5, 0.0, 0.0, 0.0])); + self.rect_fill(area); + } + }, + Paint::Solid { color, swatch, tint } => { + let ink = self.solid(color, swatch.as_deref(), *tint); + self.set_ink(ink); + self.rect_fill(area); + } + Paint::None => {} + } + } + + /// Fill rectangle `r` (a little larger, so its edges fall outside any clip it fills). + fn rect_fill(&mut self, r: Rect) { + let r = r.abs().inflate(1.0, 1.0); + push_nums(&mut self.body, &[r.x0, r.y0, r.width(), r.height()]); + self.line("rectfill"); + } + + /// Paint stroke `st` of the shape `bp` (filled by `rule`; geometric bounds `bounds`). + fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { + if !stroke::is_plain(st) && self.brush_art(bp, st) { + return; + } + self.note_item(st.opacity, st.blend); + let paint_bounds = st.paint_bounds(bounds); + let area_paint = match &st.paint { + Paint::Pattern { .. } => true, + Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform, + _ => false, + }; + if area_paint { + // The area the stroke paints, as Outline Stroke makes it, painted with the pattern's + // tiles or the freeform's image. + let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st).to_bezpath(); + self.fill(®ion, FillRule::NonZero, &st.paint, paint_bounds, st.overprint); + return; + } + let w = stroke::for_writer(bp, st); + let sided = match w.side { + Some(StrokeAlign::Inside) => { + self.line("q"); + push_path(&mut self.body, bp); + self.body.push_str(clip_op(rule)); + true + } + Some(StrokeAlign::Outside) => { + // Everything outside the path: a frame around all the stroke reaches plus the + // path, even-odd. + let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1); + outside.extend(bp.iter()); + self.line("q"); + push_path(&mut self.body, &outside); + self.body.push_str(clip_op(FillRule::EvenOdd)); + true + } + _ => false, + }; + match &w.shape { + WrittenShape::Stroke { width } => { + let on = self.overprint_on(st.overprint); + self.line_style(st, *width); + match &st.paint { + Paint::Solid { color, swatch, tint } => { + let ink = self.solid(color, swatch.as_deref(), *tint); + self.set_ink(ink); + push_path(&mut self.body, bp); + self.line("S"); + } + p => { + // A gradient within the stroke: the stroke's outline as a clip. + self.line("q"); + push_path(&mut self.body, bp); + self.line("strokepath W"); + self.paint_area(p, paint_bounds, w.reach(st, bounds)); + self.line("Q"); + } + } + self.overprint_off(on); + } + WrittenShape::Fill(outlines) if st.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines. + if let Some(ws) = stroke::written_slices(bp, rule, st, outlines) { + self.line("q"); + push_path(&mut self.body, &ws.clip); + self.body.push_str(clip_op(FillRule::NonZero)); + for (shape, paint) in &ws.slices { + self.fill(shape, FillRule::NonZero, paint, shape.bounding_box(), st.overprint); + } + self.line("Q"); + } + } + WrittenShape::Fill(outlines) => { + for o in outlines { + self.fill(o, FillRule::NonZero, &st.paint, paint_bounds, st.overprint); + } + } + } + if sided { + self.line("Q"); + } + } + + /// The line width, cap, join, miter limit and dashes of `st` at `width`. + fn line_style(&mut self, st: &StrokeLayer, width: f64) { + let cap = match st.cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + }; + let join = match st.join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + }; + let s = &mut self.body; + ps::push_num(s, width); + let _ = write!(s, " w {cap} J {join} j "); + ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); + s.push_str(" M ["); + let dash = st.dash.as_ref().filter(|d| d.is_dashed()); + if let Some(d) = dash { + push_nums(s, &d.pattern); + } + s.push_str("] "); + ps::push_num(s, dash.map_or(0.0, |d| d.offset)); + s.push_str(" d\n"); + } + + /// Paint stroke `st` of `bp` with its brush art. False when it has no known brush. + fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> bool { + if self.nest >= MAX_NEST { + return false; + } + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; + let art = vectorcraft_brush::stroke_pieces(b, bp, st); + self.note_item(st.opacity, st.blend); + self.nest += 1; + for piece in &art { + self.node(piece, false); + } + self.nest -= 1; + true + } + + // ---------- type ---------- + + /// Type as glyph outlines: the object's own fills and strokes below and above the + /// characters, each run's fill and stroke in text space. + fn text(&mut self, n: &Node, t: &TextObject) { + if !invertible(t.xf) { + return; + } + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let tb = t.xf.transform_rect_bbox(layout.bounds); + let (below, above) = n.appearance.split_contents(); + let all = (!n.appearance.items.is_empty()).then(|| { + let mut all = layout.to_bezpath(); + all.apply_affine(t.xf); + all + }); + if let Some(bp) = &all { + self.text_items(below, bp, tb); + } + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(t.xf)); + for (i, run) in t.runs.iter().enumerate() { + let mut bp = BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + if bp.elements().is_empty() { + continue; + } + self.fill(&bp, FillRule::NonZero, &run.style.fill, layout.bounds, run.style.overprint_fill); + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. + self.stroke(&bp, FillRule::NonZero, &run.style.stroke_layer(), layout.bounds); + } + } + self.line("Q"); + if let Some(bp) = &all { + self.text_items(above, bp, tb); + } + } + + /// Some of a type object's own fills and strokes on its glyph outlines `bp` (bounds `tb`). + fn text_items(&mut self, items: &[AppearanceItem], bp: &BezPath, tb: Rect) { + for item in items { + match item { + AppearanceItem::Fill(fl) if fl.visible && !fl.paint.is_none() => { + self.note_item(fl.opacity, fl.blend); + self.fill(bp, FillRule::NonZero, &fl.paint, tb, fl.overprint); + } + AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0 && st.width.is_finite() => { + self.stroke(bp, FillRule::NonZero, st, tb); + } + _ => {} + } + } + } + + // ---------- gradients ---------- + + /// The colour space a gradient's stops are written in, and its stops in it from offset 0 to 1 + /// (midpoints as stops of their own). + fn gradient_stops(&mut self, g: &vectorcraft_color::Gradient) -> (Space, Vec<(f64, [f32; 4])>) { + if g.stops.iter().any(|s| s.opacity < 1.0) { + self.warn(TRANSPARENCY); + } + let doc = self.doc; + if g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + self.warn(SPOT_GRADIENTS); + } + let stops = g.expanded_stops(); + let space = if self.cmyk || stops.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { + Space::Cmyk + } else if stops.iter().all(|(_, c, _)| matches!(c, Color::Gray { .. })) { + Space::Gray + } else { + Space::Rgb + }; + let comps = |c: &Color| -> [f32; 4] { + match (space, *c) { + (Space::Cmyk, _) => self.cms.to_cmyk(c, self.cms.settings().intent).map(unit), + (Space::Gray, Color::Gray { k }) => [unit(1.0 - k), 0.0, 0.0, 0.0], + (_, Color::Rgb { r, g, b }) => [r, g, b, 0.0].map(unit), + _ => { + let [r, g, b] = c.to_rgb(); + [r, g, b, 0.0].map(unit) + } + } + }; + let mut out: Vec<(f64, [f32; 4])> = vec![]; + let mut last = 0.0f64; + for (o, c, _) in &stops { + let o = if o.is_finite() { f64::from(*o).clamp(last, 1.0) } else { last }; + last = o; + out.push((o, comps(c))); + } + match (out.first().copied(), out.last().copied()) { + (Some(first), Some(end)) => { + if first.0 > 0.0 { + out.insert(0, (0.0, first.1)); + } + if end.0 < 1.0 { + out.push((1.0, end.1)); + } + } + _ => out = vec![(0.0, [0.0; 4]), (1.0, [0.0; 4])], + } + (space, out) + } + + /// Paint all of `area` (inside the current clip) with linear or radial gradient `g`, placed on + /// `bounds`: a smooth shading at Level 3, else stepped fills. + fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { + let geom = g.resolve(bounds); + let (space, stops) = self.gradient_stops(&g.gradient); + if !stops.windows(2).any(|w| w[1].0 > w[0].0) { + if let Some((_, c)) = stops.first() { + self.set_comps(space, *c); + self.rect_fill(area); + } + return; + } + let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; + match g.gradient.kind { + GradientKind::Radial => { + let r = geom.length().max(1e-6); + let squash = geom.radial_squash(); + let inv = squash.inverse(); + let (f, c) = (inv * geom.focal_point(), geom.start); + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(squash)); + if smooth { + self.shading(3, space, &[f.x, f.y, 0.0, c.x, c.y, r], &stops); + } else { + self.radial_steps(f, c, r, inv.transform_rect_bbox(area), space, &stops); + } + self.line("Q"); + } + _ => { + let (s, mut e) = (geom.start, geom.end); + if s.distance(e) < 1e-9 { + e = s + Vec2::new(1.0, 0.0); + } + if smooth { + self.shading(2, space, &[s.x, s.y, e.x, e.y], &stops); + } else { + self.linear_steps(s, e, area, space, &stops); + } + } + } + } + + /// A Level 3 smooth shading of `kind` (2 axial, 3 radial) at `coords`, extended both ways. + fn shading(&mut self, kind: u8, space: Space, coords: &[f64], stops: &[(f64, [f32; 4])]) { + let n = space.n(); + let comps = |c: &[f32; 4]| -> String { + let mut s = String::from("["); + push_nums(&mut s, &c.iter().take(n).map(|v| f64::from(*v)).collect::>()); + s.pop(); + s.push(']'); + s + }; + let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); + let interp = |w: &&[(f64, [f32; 4])]| format!("<< /FunctionType 2 /Domain [0 1] /C0 {} /C1 {} /N 1 >>", comps(&w[0].1), comps(&w[1].1)); + let function = match segments.as_slice() { + [one] => interp(one), + many => { + let mut f = String::from("<< /FunctionType 3 /Domain [0 1] /Functions [\n"); + for w in many { + f.push_str(&interp(w)); + f.push('\n'); + } + f.push_str("] /Bounds ["); + let bounds: Vec = many.iter().skip(1).map(|w| w[0].0).collect(); + push_nums(&mut f, &bounds); + f.push_str("] /Encode ["); + f.push_str(&"0 1 ".repeat(many.len())); + f.push_str("] >>"); + f + } + }; + let _ = write!(self.body, "<< /ShadingType {kind} /ColorSpace {} /Coords [", space.name()); + push_nums(&mut self.body, coords); + let _ = writeln!(self.body, "] /Extend [true true]\n/Function {function} >> shfill"); + } + + /// The bands of each stretch between stops: how many, for colours `a` to `b` over `length` + /// points (one per colour level, none thinner than [`MIN_BAND`]). + fn bands(a: &[f32; 4], b: &[f32; 4], length: f64, segments: usize) -> usize { + let delta = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + let by_color = (f64::from(delta) * 255.0).ceil().max(1.0) as usize; + let by_size = (length / MIN_BAND).ceil().max(1.0) as usize; + by_color.min(by_size).min(MAX_STEPS / segments.max(1)).max(1) + } + + fn set_comps(&mut self, space: Space, c: [f32; 4]) { + self.set_ink(Ink::Process(space, c)); + } + + /// A linear gradient from `s` to `e` as bands across it, covering `area`: each band painted + /// from where it starts to the far end, so the next one leaves no seam. + fn linear_steps(&mut self, s: Point, e: Point, area: Rect, space: Space, stops: &[(f64, [f32; 4])]) { + let d = e - s; + let len = d.hypot(); + // Gradient space: x along the gradient (0 to 1), y across it in points. + let frame = Affine::new([d.x, d.y, -d.y / len, d.x / len, s.x, s.y]); + let local = frame.inverse().transform_rect_bbox(area.inflate(1.0, 1.0)); + let (t0, t1) = (local.x0.min(0.0), local.x1.max(1.0)); + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(frame)); + let band = |sc: &mut Self, from: f64| { + push_nums(&mut sc.body, &[from, local.y0, t1 - from, local.height()]); + sc.line("rectfill"); + }; + if let Some((_, c)) = stops.first() { + self.set_comps(space, *c); + band(self, t0); + } + let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); + for w in &segments { + let ((oa, ca), (ob, cb)) = (w[0], w[1]); + let n = Self::bands(&ca, &cb, (ob - oa) * len, segments.len()); + for k in 0..n { + let at = oa + (ob - oa) * k as f64 / n as f64; + self.set_comps(space, mix(&ca, &cb, (k as f32 + 0.5) / n as f32)); + band(self, at); + } + } + if let Some((_, c)) = stops.last() { + self.set_comps(space, *c); + band(self, 1.0); + } + self.line("Q"); + } + + /// A radial gradient from focal point `f` to the circle of radius `r` around `c` as discs, + /// largest first, over the last colour across `area` (all in the gradient's own space). + fn radial_steps(&mut self, f: Point, c: Point, r: f64, area: Rect, space: Space, stops: &[(f64, [f32; 4])]) { + if let Some((_, last)) = stops.last() { + self.set_comps(space, *last); + self.rect_fill(area); + } + let segments: Vec<_> = stops.windows(2).filter(|w| w[1].0 > w[0].0).collect(); + for w in segments.iter().rev() { + let ((oa, ca), (ob, cb)) = (w[0], w[1]); + let n = Self::bands(&ca, &cb, (ob - oa) * r, segments.len()); + for k in (0..n).rev() { + let t = oa + (ob - oa) * (k + 1) as f64 / n as f64; + self.set_comps(space, mix(&ca, &cb, (k as f32 + 0.5) / n as f32)); + let at = f + (c - f) * t; + push_nums(&mut self.body, &[at.x, at.y, r * t]); + self.line("0 360 newpath arc fill"); + } + } + } + + // ---------- images ---------- + + /// Paint all of `area` with an image of freeform gradient `g` placed on `bounds`, sampled at + /// the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side). + fn field_image(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { + use vectorcraft_color::freeform::{painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let area = area.abs(); + let long = area.width().max(area.height()); + if !(long > 1e-9 && long.is_finite()) { + return; + } + let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; + let (cols, rows) = (along(area.width()), along(area.height())); + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + let q = |v: f32| (unit(v) * 255.0).round() as u8; + let rgba: Vec = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); + let place = Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width(), area.height()); + if let Some(image) = self.encode_image(&rgba, u32::from(cols), u32::from(rows)) { + self.place_image(&image, place); + } + } + + /// A placed image: its pixel grid stretched over its `width` × `height` (each image file + /// encoded once). + fn image(&mut self, im: &ImageObject) { + let place = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)), f64::from(im.height.max(1))); + if !invertible(place) { + return; + } + let encoded = match self.images.get(&im.key) { + Some(e) => e.clone(), + None => { + let e = self.decode_image(&im.key).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); + self.images.insert(im.key.clone(), e.clone()); + e + } + }; + if let Some(e) = encoded { + self.place_image(&e, place); + } + } + + /// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written. + fn decode_image(&mut self, key: &str) -> Option { + let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else { + self.warn(&format!("image '{key}' could not be decoded and was left out")); + return None; + }; + if u64::from(img.width()) * u64::from(img.height()) > MAX_IMAGE_PIXELS { + self.warn(&format!("image '{key}' is too large for EPS and was left out")); + return None; + } + Some(img.to_rgba8()) + } + + /// Draw an image of [`Self::encode_image`] on the unit square mapped by `place`. + fn place_image(&mut self, encoded: &str, place: Affine) { + if !invertible(place) { + return; + } + self.line("q"); + let _ = writeln!(self.body, "{} cm", ps::matrix(place)); + self.body.push_str(encoded); + self.line("Q"); + } + + /// Straight RGBA pixels (`w` × `h`, rows from the top) as the operators and data that draw + /// them on the unit square: in the process colour space, transparent pixels masked out at + /// Level 3 (a key colour no opaque pixel has), white at Level 2; partly transparent ones over + /// white. `None` for an empty image. + fn encode_image(&mut self, rgba: &[u8], w: u32, h: u32) -> Option { + if w == 0 || h == 0 { + return None; + } + let px = rgba.as_chunks::<4>().0; + let clear = px.iter().any(|p| p[3] == 0); + if px.iter().any(|p| p[3] > 0 && p[3] < 255) { + self.warn(IMAGE_PARTLY); + } + let keyed = clear && self.o.level == Level::Three; + if clear && !keyed { + self.warn(IMAGE_ALPHA_L2); + } + let space = if self.cmyk { Space::Cmyk } else { Space::Rgb }; + let n = space.n(); + // The key colour, which opaque pixels that happen to have it are nudged off. + let key: [u8; 4] = [255, 0, 255, 0]; + // White: no ink, or full light. + let white: [u8; 4] = if space == Space::Cmyk { [0; 4] } else { [255; 4] }; + let mut cache: HashMap<[u8; 3], [u8; 4]> = HashMap::new(); + let cms = self.cms.clone(); + let intent = cms.settings().intent; + let mut data = Vec::with_capacity(px.len() * n); + for p in px { + if p[3] == 0 { + data.extend(if keyed { &key } else { &white }.iter().take(n)); + continue; + } + let al = u32::from(p[3]); + let rgb = [0, 1, 2].map(|i| ((u32::from(p[i]) * al + 255 * (255 - al) + 127) / 255) as u8); + let mut v = match space { + Space::Cmyk => *cache + .entry(rgb) + .or_insert_with(|| cms.srgb_to_cmyk(rgb.map(|x| f32::from(x) / 255.0), intent).map(|x| (unit(x) * 255.0).round() as u8)), + _ => [rgb[0], rgb[1], rgb[2], 0], + }; + if keyed && v.iter().take(n).eq(key.iter().take(n)) { + v[1] = 1; + } + data.extend(v.iter().take(n)); + } + let (filter, encoded) = match self.o.level { + Level::Three => ("/FlateDecode", ps::deflate(&data)), + Level::Two => { + let mut rl = Vec::with_capacity(data.len() / 2); + ps::packbits(&data, &mut rl); + // End of data. + rl.push(128); + ("/RunLengthDecode", rl) + } + }; + let mut out = String::with_capacity(encoded.len() * 5 / 4 + 400); + let _ = writeln!(out, "{} setcolorspace", space.name()); + let decode = "0 1 ".repeat(n); + let _ = write!( + out, + "{{ /VCsrc currentfile /ASCII85Decode filter def\n<< /ImageType {} /Width {w} /Height {h} /BitsPerComponent 8 /Decode [{}] /ImageMatrix [{w} 0 0 {h} 0 0]", + if keyed { 4 } else { 1 }, + decode.trim_end() + ); + if keyed { + let mask: Vec = key.iter().take(n).map(|v| format!("{v} {v}")).collect(); + let _ = write!(out, " /MaskColor [{}]", mask.join(" ")); + } + // The procedure is read whole before it runs: the image data follows it, and flushing the + // ASCII85 filter afterwards reads past its end marker. + let _ = writeln!(out, "\n/DataSource VCsrc {filter} filter >> image\nVCsrc flushfile }} exec"); + out.push_str(&ps::ascii85(&encoded)); + Some(out) + } +} + +/// `a` to `b` at `t`. +fn mix(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { + [0, 1, 2, 3].map(|i| a[i] + (b[i] - a[i]) * t) +} diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs new file mode 100644 index 00000000..567cdce9 --- /dev/null +++ b/crates/eps/src/tests.rs @@ -0,0 +1,466 @@ +//! EPS output read back: the DSC header and bounding box, the page's operators, gradients at both +//! levels, images, colours, the preview header and TIFF, and the data the file carries. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; + +use crate::*; + +/// A 200 × 200 pt document whose first layer holds what `nodes` makes. +fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 200.0); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width)) +} + +fn red_rect(d: &mut Document) -> Node { + path(d, shapes::rectangle(Rect::new(50.0, 60.0, 150.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0) +} + +/// The first artboard as the region, its bottom-left corner the origin; no preview. +fn opts(d: &Document) -> EpsOptions { + let r = d.artboards[0].rect; + EpsOptions { region: r, origin: Point::new(r.x0, r.y1), preview: Preview::None, ..EpsOptions::default() } +} + +fn text(out: &EpsOutput) -> String { + String::from_utf8(sections(&out.bytes).unwrap().0.to_vec()).unwrap() +} + +fn page_of(d: &Document, o: &EpsOptions) -> String { + checked_page(&text(&export(d, o, None, None).unwrap())) +} + +/// The value of DSC comment `key` (`%%Key:`). +fn dsc(ps: &str, key: &str) -> Option { + let prefix = format!("%%{key}:"); + ps.lines().find_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string()) +} + +/// The program, checked for balanced procedures, arrays, dictionaries, strings and graphics +/// state saves (the syntax a PostScript interpreter would reject first); image data and comments +/// are skipped. +fn checked_page(ps: &str) -> String { + let mut code = String::new(); + let mut lines = ps.lines(); + while let Some(l) = lines.next() { + if !l.starts_with('%') { + code.push_str(l); + code.push('\n'); + } + // Image data follows the procedure that reads it, up to the ASCII85 end marker. + if l.ends_with("} exec") { + assert!(lines.any(|d| d.contains("~>")), "image data ends"); + } + } + let mut stack = vec![]; + let mut chars = code.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '(' => { + let mut depth = 1; + while depth > 0 { + match chars.next().expect("string closes") { + '\\' => { + chars.next(); + } + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + } + } + '{' | '[' => stack.push(c), + '<' if chars.peek() == Some(&'<') => { + chars.next(); + stack.push('<'); + } + '>' if chars.peek() == Some(&'>') => { + chars.next(); + assert_eq!(stack.pop(), Some('<'), "dictionary closes"); + } + '}' => assert_eq!(stack.pop(), Some('{'), "procedure closes"), + ']' => assert_eq!(stack.pop(), Some('['), "array closes"), + _ => {} + } + } + assert!(stack.is_empty(), "unclosed: {stack:?}"); + let count = |op: &str| code.split_whitespace().filter(|t| *t == op).count(); + assert_eq!(count("q"), count("Q"), "gsave and grestore pair up"); + code +} + +#[test] +fn the_header_follows_dsc_and_the_file_ends_with_eof() { + let d = doc_with(|d| vec![red_rect(d)]); + let out = export(&d, &EpsOptions { title: "Poster (draft)".into(), created: Some(0), ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert!(ps.starts_with("%!PS-Adobe-3.0 EPSF-3.0\n"), "{ps}"); + assert!(dsc(&ps, "Creator").unwrap().starts_with("VectorCraft")); + assert_eq!(dsc(&ps, "Title").as_deref(), Some("(Poster \\(draft\\))")); + assert_eq!(dsc(&ps, "CreationDate").as_deref(), Some("(1970-01-01 00:00:00 UTC)")); + assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("3")); + assert_eq!(dsc(&ps, "DocumentData").as_deref(), Some("Clean7Bit")); + assert_eq!(dsc(&ps, "Pages").as_deref(), Some("1")); + let order = ["%%EndComments", "%%BeginProlog", "%%EndProlog", "%%BeginSetup", "%%EndSetup", "%%Page: 1 1", "showpage", "%%Trailer", "%%EOF"]; + let at: Vec = order.iter().map(|m| ps.find(m).unwrap_or_else(|| panic!("{m}"))).collect(); + assert!(at.windows(2).all(|w| w[0] < w[1]), "sections in order"); + assert!(ps.ends_with("%%EOF\n")); + assert!(ps.is_ascii() && ps.lines().all(|l| l.len() <= 255), "7-bit lines DSC readers take"); + let page = checked_page(&ps); + assert!(page.contains("1 0 0 rg\n50 60 m\n150 60 l\n150 100 l\n50 100 l\n50 60 l\nh\nf\n"), "red, in document space: {page}"); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn the_bounding_box_is_y_up_from_the_origin() { + let d = doc_with(|d| vec![red_rect(d)]); + // The art's bounds on a 200 pt artboard: 60..100 down from the top is 100..140 up from the bottom. + let art = Rect::new(50.0, 60.0, 150.5, 100.0); + let out = export(&d, &EpsOptions { region: art, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("50 100 151 140")); + assert_eq!(dsc(&ps, "HiResBoundingBox").as_deref(), Some("50 100 150.5 140")); + // The page maps document space onto it: y flipped about the artboard's bottom edge. + assert!(ps.contains("[1 0 0 -1 0 200] cm"), "{ps}"); + assert_eq!(bounding_box(art, Point::new(0.0, 200.0)), [50, 100, 151, 140]); + assert_eq!(preview_rect(art, Point::new(0.0, 200.0)), Rect::new(50.0, 60.0, 151.0, 100.0)); + // An artboard of its own: the origin at its bottom-left corner. + let ab = Rect::new(300.0, -50.0, 400.0, 50.0); + assert_eq!(bounding_box(ab, Point::new(300.0, 50.0)), [0, 0, 100, 100]); + // Objects off the region are left out. + let off = page_of(&d, &EpsOptions { region: ab, origin: Point::new(300.0, 50.0), ..opts(&d) }); + assert!(off.contains("[1 0 0 -1 -300 50] cm") && !off.contains(" rg\n"), "nothing drawn: {off}"); +} + +fn gradient_doc(kind: GradientKind) -> Document { + let stops = vec![ + GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), + GradientStop::new(0.4, Color::rgb(1.0, 1.0, 0.0)), + GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), + ]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))); + doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 120.0)), paint, Paint::None, 0.0)]) +} + +#[test] +fn gradients_are_smooth_shadings_at_level_3_and_stepped_fills_at_level_2() { + let linear = gradient_doc(GradientKind::Linear); + let page = page_of(&linear, &opts(&linear)); + assert!(page.contains("/ShadingType 2 /ColorSpace /DeviceRGB"), "{page}"); + assert!(page.contains("/FunctionType 3") && page.contains("/Bounds [0.4 ]") && page.contains("/Encode [0 1 0 1 ]"), "{page}"); + assert!(page.contains("/C0 [1 0 0] /C1 [1 1 0]") && page.contains("/C0 [1 1 0] /C1 [0 0 1]"), "{page}"); + let clip = page.find("W\n").unwrap(); + assert!(page[clip..].contains("shfill"), "clipped to the shape: {page}"); + let radial = gradient_doc(GradientKind::Radial); + assert!(page_of(&radial, &opts(&radial)).contains("/ShadingType 3")); + + for (d, o) in [ + (&linear, EpsOptions { level: Level::Two, ..opts(&linear) }), + (&radial, EpsOptions { level: Level::Two, ..opts(&radial) }), + (&linear, EpsOptions { compatible_gradients: true, ..opts(&linear) }), + ] { + let page = page_of(d, &o); + assert!(!page.contains("shfill") && !page.contains("ShadingType"), "{page}"); + let bands = page.matches("rectfill").count() + page.matches("arc fill").count(); + assert!(bands > 100, "a band per colour step: {bands}"); + assert!( + page.contains("0 0 1 rg") + && page.contains( + " 0 rg +" + ), + "from red through yellow to blue" + ); + } + assert!(page_of(&radial, &EpsOptions { level: Level::Two, ..opts(&radial) }).contains("0 360 newpath arc fill")); + let ps = text(&export(&linear, &EpsOptions { level: Level::Two, ..opts(&linear) }, None, None).unwrap()); + assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("2")); +} + +#[test] +fn strokes_keep_their_caps_joins_and_dashes() { + let d = doc_with(|d| { + let mut n = path(d, shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 4.0); + for i in &mut n.appearance.items { + if let AppearanceItem::Stroke(s) = i { + s.cap = LineCap::Round; + s.join = LineJoin::Bevel; + s.dash = Some(Dash { pattern: vec![6.0, 2.0], offset: 1.0, ..Dash::default() }); + } + } + vec![n] + }); + let page = page_of(&d, &opts(&d)); + assert!(page.contains("4 w 1 J 2 j 10 M [6 2 ] 1 d\n"), "{page}"); + assert!(page.contains("0 0 0 rg\n20 20 m\n80 20 l\n80 80 l\n20 80 l\n20 20 l\nh\nS\n"), "{page}"); +} + +#[test] +fn transparency_is_written_opaque_with_a_warning() { + let d = doc_with(|d| { + let mut n = red_rect(d); + n.opacity = 0.5; + vec![n] + }); + let out = export(&d, &opts(&d), None, None).unwrap(); + assert!(text(&out).contains("1 0 0 rg"), "drawn, opaque"); + assert!(out.warnings.iter().any(|w| w.contains("written opaque")), "{:?}", out.warnings); +} + +#[test] +fn spot_colours_are_separations_and_overprints_are_kept_or_dropped() { + let mut d = doc_with(|_| vec![]); + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + d.swatches.push(Swatch { name: "Gold Ink".into(), paint: Paint::solid(gold), global: true, spot: true }); + let mut n = red_rect(&mut d); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Solid { color: gold, swatch: Some("Gold Ink".into()), tint: 0.5 }; + f.overprint = true; + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)")); + assert_eq!(dsc(&ps, "CMYKCustomColor").as_deref(), Some("0 0.2 0.8 0.1 (Gold Ink)")); + assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK {dup 0 mul exch dup 0.2 mul exch dup 0.8 mul exch 0.1 mul}] def"), "{ps}"); + let page = checked_page(&ps); + assert!(page.contains("true op\nVCsp0 setcolorspace 0.5 setcolor\n") && page.contains("false op"), "{page}"); + assert!(!page_of(&d, &EpsOptions { overprint: Overprint::Discard, ..opts(&d) }).contains("true op")); +} + +#[test] +fn rgb_is_written_as_cmyk_when_asked_and_in_cmyk_documents() { + let d = doc_with(|d| vec![red_rect(d)]); + assert!(page_of(&d, &opts(&d)).contains("1 0 0 rg")); + let cmyk = page_of(&d, &EpsOptions { cmyk: true, ..opts(&d) }); + assert!(!cmyk.contains(" rg\n") && cmyk.lines().any(|l| l.ends_with(" k") && l.split(' ').count() == 5), "{cmyk}"); + let mut c = d.clone(); + c.color_mode = vectorcraft_doc::ColorMode::Cmyk; + assert!(!page_of(&c, &opts(&c)).contains(" rg\n")); +} + +/// A 4 × 2 PNG placed 10 pt a pixel: the left half opaque green, the right half transparent. +fn png_doc() -> Document { + let mut img = image::RgbaImage::new(4, 2); + for (x, _, p) in img.enumerate_pixels_mut() { + *p = if x < 2 { image::Rgba([0, 200, 0, 255]) } else { image::Rgba([0, 0, 0, 0]) }; + } + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(200.0, 200.0); + d.images.insert("pic".into(), ImageBlob::new("image/png", png)); + let im = ImageObject { + key: "pic".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), + link: None, + placement: Default::default(), + }; + let n = Node::new(d.alloc_id(), NodeKind::Image(im)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + d +} + +/// The image data after the first image procedure, ASCII85-decoded. +fn image_data(page: &str) -> Vec { + let at = page.find("} exec\n").unwrap() + 7; + let end = page[at..].find("~>").unwrap() + at + 2; + ps::ascii85_decode(&page[at..end]).unwrap() +} + +#[test] +fn images_are_flate_with_masked_pixels_at_level_3_and_run_length_at_level_2() { + let d = png_doc(); + let l3 = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&l3); + checked_page(&ps); + assert!(ps.contains("[40 0 0 20 10 10] cm\n/DeviceRGB setcolorspace"), "the pixel grid on its placement: {ps}"); + assert!( + ps.contains( + "/ImageType 4 /Width 4 /Height 2 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /ImageMatrix [4 0 0 2 0 0] /MaskColor [255 255 0 0 255 255]" + ), + "{ps}" + ); + assert!(ps.contains("/DataSource VCsrc /FlateDecode filter >> image\nVCsrc flushfile } exec\n"), "{ps}"); + assert!(l3.warnings.is_empty(), "{:?}", l3.warnings); + let pixels = ps::inflate(&image_data(&ps), 1 << 20).unwrap(); + let (g, key) = ([0, 200, 0], [255, 0, 255]); + let row = [g, g, key, key].concat(); + assert_eq!(pixels, [row.clone(), row].concat()); + let l2 = export(&d, &EpsOptions { level: Level::Two, ..opts(&d) }, None, None).unwrap(); + let ps = text(&l2); + checked_page(&ps); + assert!(ps.contains("/ImageType 1") && ps.contains("/RunLengthDecode filter") && !ps.contains("MaskColor"), "{ps}"); + let row = [g, g, [255; 3], [255; 3]].concat(); + assert_eq!(unpack(&image_data(&ps)), [row.clone(), row].concat()); + assert!(l2.warnings.iter().any(|w| w.contains("written white at PostScript Level 2")), "{:?}", l2.warnings); +} + +/// Run-length data decoded up to its end marker. +fn unpack(data: &[u8]) -> Vec { + let mut out = vec![]; + let mut i = 0; + while let Some(&n) = data.get(i) { + match n { + 128 => break, + 0..=127 => { + out.extend(&data[i + 1..i + 2 + n as usize]); + i += 2 + n as usize; + } + _ => { + out.extend(std::iter::repeat_n(data[i + 1], 257 - n as usize)); + i += 2; + } + } + } + out +} + +#[test] +fn run_length_and_ascii85_round_trip() { + let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); + for data in [vec![], vec![7], vec![1, 2, 3], vec![9; 300], cycle, [vec![0; 9], vec![1, 2, 2, 2, 2, 3]].concat()] { + let mut rl = vec![]; + ps::packbits(&data, &mut rl); + rl.push(128); + assert_eq!(unpack(&rl), data); + let a85 = ps::ascii85(&data); + assert!(a85.lines().all(|l| !l.starts_with('%') && l.len() <= 80), "{a85}"); + assert_eq!(ps::ascii85_decode(&a85).unwrap(), data); + } + assert_eq!(ps::ascii85(b"Man "), "9jqo^~>\n"); + assert_eq!(ps::ascii85(&[0, 0, 0, 0]), "z~>\n"); + assert!(ps::ascii85_decode("9jqo^").is_none(), "no end marker"); + assert!(ps::ascii85_decode("9jqo^v~>").is_none(), "not base 85"); + assert_eq!(ps::num(1.0 / 3.0), "0.3333"); + assert_eq!((ps::num(-0.00001), ps::num(f64::NAN), ps::num(2.5)), ("0".into(), "0".into(), "2.5".into())); + assert_eq!(ps::string("a(b)\\é"), "(a\\(b\\)\\\\\\303\\251)"); +} + +/// A 3 × 2 preview: black, white, transparent; then red, green, blue. +fn preview() -> Raster { + let rgba = vec![0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; + Raster { width: 3, height: 2, rgba } +} + +#[test] +fn a_tiff_preview_sits_behind_the_binary_header() { + let d = doc_with(|d| vec![red_rect(d)]); + for (kind, transparent) in [(Preview::TiffColor, true), (Preview::TiffColor, false), (Preview::TiffBlackWhite, true)] { + let out = export(&d, &EpsOptions { preview: kind, transparent_preview: transparent, ..opts(&d) }, Some(&preview()), None).unwrap(); + let b = &out.bytes; + let word = |at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) as usize; + assert_eq!(&b[..4], &[0xC5, 0xD0, 0xD3, 0xC6]); + let (ps_at, ps_len, tiff_at, tiff_len) = (word(4), word(8), word(20), word(24)); + assert_eq!((ps_at, word(12), word(16)), (30, 0, 0), "PostScript right after the header; no metafile"); + assert_eq!(tiff_at, ps_at + ps_len, "the TIFF follows the PostScript"); + assert_eq!(tiff_at + tiff_len, b.len()); + assert_eq!(&b[28..30], &[0xFF, 0xFF], "no checksum"); + assert!(b[ps_at..].starts_with(b"%!PS-Adobe-3.0 EPSF-3.0") && b[..tiff_at].ends_with(b"%%EOF\n")); + let tiff = &b[tiff_at..]; + assert!(tiff.starts_with(b"II*\0")); + assert_eq!(sections(b).unwrap().1, Some(tiff)); + if kind == Preview::TiffBlackWhite { + // A bit a pixel, a byte a row, always opaque: black, white, (transparent) white; red + // and blue are darker than mid-grey, green isn't. The strip is the file's end. + let strip = &tiff[tiff.len() - 4..]; + assert_eq!(unpack(&[strip, &[128]].concat()), [0b1000_0000, 0b1010_0000]); + continue; + } + let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (3, 2)); + let expect: [u8; 4] = if transparent { [0, 0, 0, 0] } else { [255; 4] }; + assert_eq!(img.get_pixel(2, 0).0, expect, "transparent, or on white"); + assert_eq!(img.get_pixel(0, 1).0, [255, 0, 0, 255]); + } + // Asked for but not rendered: the file has no preview, and says so. + let out = export(&d, &EpsOptions { preview: Preview::TiffColor, ..opts(&d) }, None, None).unwrap(); + assert!(out.bytes.starts_with(b"%!PS") && out.warnings.iter().any(|w| w.contains("preview"))); +} + +#[test] +fn the_native_document_and_thumbnail_come_back() { + let d = doc_with(|d| vec![red_rect(d)]); + let native: Vec = (0..20_000u32).flat_map(|i| (i % 251).to_le_bytes()).collect(); + for kind in [Preview::None, Preview::TiffColor] { + let o = EpsOptions { native: Some(native.clone()), preview: kind, ..opts(&d) }; + let out = export(&d, &o, Some(&preview()), Some(&preview())).unwrap(); + assert_eq!(super::native(&out.bytes).as_deref(), Some(&native[..])); + let png = super::thumbnail(&out.bytes).unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().dimensions(), (3, 2)); + let ps = text(&out); + checked_page(&ps); + assert!(ps.find("%VectorCraft_BeginData: thumbnail").unwrap() < ps.find("%%BeginProlog").unwrap()); + assert!(ps.find("%VectorCraft_BeginData: native").unwrap() > ps.find("%%Trailer").unwrap()); + assert!(ps.lines().all(|l| l.len() <= 255)); + } + let plain = export(&d, &opts(&d), None, None).unwrap(); + assert_eq!((super::native(&plain.bytes), super::thumbnail(&plain.bytes)), (None, None)); + // Damaged files give nothing back. + assert_eq!(super::native(b"%VectorCraft_BeginData: native\n% 9jqo^\n%VectorCraft_EndData\n"), None); + let mut bad = vec![0xC5, 0xD0, 0xD3, 0xC6]; + bad.extend([0xFF; 26]); + assert_eq!(sections(&bad), None); +} + +#[test] +fn clipping_groups_clip_and_hidden_or_template_art_is_left_out() { + let mut d = doc_with(|d| { + let clip = path(d, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Paint::None, Paint::None, 0.0); + let inside = red_rect(d); + let mut hidden = path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + hidden.visible = false; + let id = d.alloc_id(); + vec![Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inside)], clip: true }), hidden] + }); + let mut t = Node::layer(d.alloc_id(), "Template", vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template, .. } = &mut t.kind { + *template = true; + } + let blue = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + *t.children_mut().unwrap() = vec![Arc::new(blue)]; + d.layers.push(Arc::new(t)); + let page = page_of(&d, &opts(&d)); + let clip = page.find("W\n").unwrap(); + assert!(page[..clip].contains("q\n") && page[clip..].contains("1 0 0 rg"), "{page}"); + assert!(!page.contains("0 0 1 rg"), "hidden and template art: {page}"); +} + +#[test] +fn level_preview_and_overprint_ids_read_back() { + for l in Level::ALL { + assert_eq!(Level::from_id(l.id()), Some(l)); + } + assert_eq!(Level::from_id("LanguageLevel 2"), Some(Level::Two)); + assert_eq!(Level::from_id("1"), None); + for p in Preview::ALL { + assert_eq!(Preview::from_id(p.id()), Some(p)); + } + assert_eq!(Overprint::from_id("DISCARD"), Some(Overprint::Discard)); +} + +#[test] +fn a_rich_document_is_well_formed_at_both_levels() { + let d = (*vectorcraft_testkit::fixtures::rich_session().doc().unwrap().doc).clone(); + for level in Level::ALL { + for cmyk in [false, true] { + let out = export(&d, &EpsOptions { level, cmyk, ..opts(&d) }, None, None).unwrap(); + let page = checked_page(&text(&out)); + assert!(page.lines().count() > 50, "{page}"); + } + } +} diff --git a/crates/eps/src/tiff.rs b/crates/eps/src/tiff.rs new file mode 100644 index 00000000..2a618275 --- /dev/null +++ b/crates/eps/src/tiff.rs @@ -0,0 +1,146 @@ +//! The TIFF preview (baseline TIFF, little-endian, one PackBits strip): 1-bit black and white, or +//! 8-bit RGB with or without an alpha channel. Also the PNG thumbnail. + +use crate::Raster; +use crate::ps::packbits; + +/// TIFF field types. +const SHORT: u16 = 3; +const LONG: u16 = 4; +const RATIONAL: u16 = 5; + +/// One directory entry: tag, type, count and its value (or, when longer than four bytes, the +/// bytes placed after the directory). +struct Entry { + tag: u16, + ty: u16, + count: u32, + data: Vec, +} + +impl Entry { + fn short(tag: u16, v: &[u16]) -> Self { + Self { tag, ty: SHORT, count: v.len() as u32, data: v.iter().flat_map(|x| x.to_le_bytes()).collect() } + } + fn long(tag: u16, v: u32) -> Self { + Self { tag, ty: LONG, count: 1, data: v.to_le_bytes().to_vec() } + } + fn rational(tag: u16, num: u32, den: u32) -> Self { + Self { tag, ty: RATIONAL, count: 1, data: [num.to_le_bytes(), den.to_le_bytes()].concat() } + } +} + +/// Pixel `px` composited over white: RGB. +fn on_white(px: &[u8; 4]) -> [u8; 3] { + let a = u32::from(px[3]); + [0, 1, 2].map(|i| ((u32::from(px[i]) * a + 255 * (255 - a) + 127) / 255) as u8) +} + +/// `img` as a TIFF: one bit a pixel (`bw`: black where darker than mid-grey on white), else RGB +/// over white or, with `alpha`, RGBA (unassociated alpha). +pub(crate) fn encode(img: &Raster, bw: bool, alpha: bool) -> Result, String> { + let (w, h) = (img.width as usize, img.height as usize); + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { + return Err("the preview image is empty".into()); + } + let px = img.rgba.as_chunks::<4>().0; + let spp: usize = if bw { + 1 + } else if alpha { + 4 + } else { + 3 + }; + // Each row compressed on its own, as TIFF readers expect. + let mut strip = Vec::with_capacity(w * h * spp / 4); + let mut row = Vec::with_capacity(w * spp); + for y in 0..h { + row.clear(); + let line = px.get(y * w..(y + 1) * w).unwrap_or_default(); + if bw { + row.resize(w.div_ceil(8), 0); + for (x, p) in line.iter().enumerate() { + let [r, g, b] = on_white(p); + let luma = 299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b); + // WhiteIsZero: a set bit is black. + if luma < 128_000 + && let Some(byte) = row.get_mut(x / 8) + { + *byte |= 0x80 >> (x % 8); + } + } + } else if alpha { + row.extend(line.iter().flatten()); + } else { + row.extend(line.iter().flat_map(on_white)); + } + packbits(&row, &mut strip); + } + let strip_len = u32::try_from(strip.len()).map_err(|_| "the preview image is too large".to_string())?; + let bits: Vec = vec![if bw { 1 } else { 8 }; spp]; + let mut entries = vec![ + Entry::long(256, img.width), + Entry::long(257, img.height), + Entry::short(258, &bits), + // PackBits. + Entry::short(259, &[32773]), + // WhiteIsZero, or RGB. + Entry::short(262, &[if bw { 0 } else { 2 }]), + // StripOffsets, filled in below. + Entry::long(273, 0), + Entry::short(277, &[spp as u16]), + Entry::long(278, img.height), + Entry::long(279, strip_len), + Entry::rational(282, 72, 1), + Entry::rational(283, 72, 1), + // Inches. + Entry::short(296, &[2]), + ]; + if alpha && !bw { + // Unassociated alpha. + entries.push(Entry::short(338, &[2])); + } + // Header, directory (count, entries, next = 0), the longer values, then the strip. + let dir_len = 2 + entries.len() * 12 + 4; + let mut extra_at = 8 + dir_len; + let extra_len: usize = entries.iter().filter(|e| e.data.len() > 4).map(|e| e.data.len().next_multiple_of(2)).sum(); + let strip_at = u32::try_from(8 + dir_len + extra_len).map_err(|_| "the preview image is too large".to_string())?; + if let Some(e) = entries.iter_mut().find(|e| e.tag == 273) { + e.data = strip_at.to_le_bytes().to_vec(); + } + let mut out = Vec::with_capacity(strip_at as usize + strip.len()); + out.extend(b"II"); + out.extend(42u16.to_le_bytes()); + out.extend(8u32.to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + let mut extra = vec![]; + for e in &entries { + out.extend(e.tag.to_le_bytes()); + out.extend(e.ty.to_le_bytes()); + out.extend(e.count.to_le_bytes()); + if e.data.len() <= 4 { + let mut v = e.data.clone(); + v.resize(4, 0); + out.extend(v); + } else { + out.extend((extra_at as u32).to_le_bytes()); + extra.extend(&e.data); + if e.data.len() % 2 == 1 { + extra.push(0); + } + extra_at += e.data.len().next_multiple_of(2); + } + } + out.extend(0u32.to_le_bytes()); + out.extend(extra); + out.extend(strip); + Ok(out) +} + +/// `img` as a PNG. +pub(crate) fn png(img: &Raster) -> Result, String> { + let rgba = image::RgbaImage::from_raw(img.width, img.height, img.rgba.clone()).ok_or("the thumbnail image is malformed")?; + let mut out = vec![]; + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(out) +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 4933da66..adebc83c 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -55,7 +55,10 @@ fn export_tool_lists_engine_formats_and_options() { let tools = tool_definitions(); let export = tools.iter().find(|t| t["name"] == "export").unwrap(); let props = &export["inputSchema"]["properties"]; - assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"])); + assert_eq!( + props["format"]["enum"], + json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps"]) + ); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3f650568..dc2b1d26 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,7 @@ vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } +vectorcraft-eps = { workspace = true } vectorcraft-format = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/eps_options.rs b/crates/ui-egui/src/dialogs/eps_options.rs new file mode 100644 index 00000000..bae6c263 --- /dev/null +++ b/crates/ui-egui/src/dialogs/eps_options.rs @@ -0,0 +1,129 @@ +//! EPS Options (Export As → EPS): the preview (none, TIFF black and white or colour, transparent +//! or opaque), overprints and the flattener preset transparency is flattened with, fonts, the +//! options (linked files, thumbnails, CMYK PostScript, compatible gradients) and the PostScript +//! language level. +//! +//! Fields: `document.export` params (`format: eps`, `path?`, the artboard choice) and the EPS +//! options of `document.exportEps`. OK remembers the options for next time and writes the file +//! (asking where when there is no path); bad options keep the dialog open. + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::{FlattenOptions, fileio}; +use vectorcraft_eps::{Level, Overprint, Preview}; + +use super::DialogSpec; +use super::document_setup::{LABEL, check, choice}; +use crate::state::Dialog; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const KIND: &str = "epsOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "EPS Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM }; + +/// Fields that are not remembered between exports (they belong to one export). +const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"]; + +/// The options a first EPS Options dialog starts from (the export's defaults). +fn defaults() -> Map { + let medium = FlattenOptions::preset_label(FlattenOptions::PRESETS[1]).unwrap_or_default(); + let mut m = Map::new(); + for (k, v) in [ + ("previewFormat", json!(Preview::default().id())), + ("transparentPreview", json!(true)), + ("overprints", json!(Overprint::default().id())), + ("flattenerPreset", json!(medium)), + ("includeLinkedFiles", json!(false)), + ("thumbnails", json!(true)), + ("cmykPostScript", json!(true)), + ("compatibleGradients", json!(false)), + ("level", json!(Level::default().id())), + ] { + m.insert(k.into(), v); + } + m +} + +/// Open EPS Options for an export whose params (path, artboard choice, any EPS option) are +/// `params`, over the options used last. +pub fn open(app: &mut VectorcraftApp, params: &Value) { + let mut fields = defaults(); + for given in [&app.ui.eps_options, params] { + if let Some(o) = given.as_object() { + fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + } + fields.insert("format".into(), json!("eps")); + // A built-in preset named by id (`medium`) shows as its name. + if let Some(label) = fields.get("flattenerPreset").and_then(Value::as_str).and_then(|p| FlattenOptions::preset_label(&p.to_ascii_lowercase())) { + fields.insert("flattenerPreset".into(), json!(label)); + } + app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields))); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::subheader(ui, "Preview"); + let previews: Vec<(&str, &str)> = Preview::ALL.iter().map(|p| (p.id(), p.label())).collect(); + choice(ui, d, "previewFormat", "Format:", &previews); + // Only the colour preview can keep transparency. + let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor); + let transparent = d.bool("transparentPreview"); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + for (label, on) in [("Transparent", true), ("Opaque", false)] { + if widgets::radio(ui, label, color && transparent == on, color) { + d.fields.insert("transparentPreview".into(), json!(on)); + } + ui.add_space(12.0); + } + }); + ui.add_space(8.0); + widgets::subheader(ui, "Transparency"); + let overprints: Vec<(&str, &str)> = Overprint::ALL.iter().map(|o| (o.id(), o.label())).collect(); + choice(ui, d, "overprints", "Overprints:", &overprints); + let presets = app.session.flattener_presets(); + let names: Vec<(&str, &str)> = presets.iter().map(|p| (p.name.as_str(), p.name.as_str())).collect(); + choice(ui, d, "flattenerPreset", "Preset:", &names); + ui.add_space(8.0); + widgets::subheader(ui, "Fonts"); + // Type is written as glyph outlines, which need no fonts. + widgets::check(ui, "Embed Fonts (for other applications)", true, false); + ui.add_space(8.0); + widgets::subheader(ui, "Options"); + check(ui, d, "includeLinkedFiles", "Include Linked Files"); + check(ui, d, "thumbnails", "Include Document Thumbnails"); + check(ui, d, "cmykPostScript", "Include CMYK PostScript in RGB Files"); + check(ui, d, "compatibleGradients", "Compatible Gradient and Gradient Mesh Printing"); + ui.add_space(4.0); + let levels: Vec<(&str, &str)> = Level::ALL.iter().map(|l| (l.id(), l.label())).collect(); + // A level set as a number (`level: 2`) reads as its id. + if let Some(Value::Number(n)) = d.fields.get("level") { + let id = n.to_string(); + d.fields.insert("level".into(), json!(id)); + } + choice(ui, d, "level", "PostScript:", &levels); + ui.add_space(8.0); + super::form::caption( + ui, + if Level::from_id(&d.str("level")) == Some(Level::Two) || d.bool("compatibleGradients") { + "Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour." + } else { + "Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth." + }, + ); + false +} + +/// Check the options, remember them, and write the file(s). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let params = fileio::eps::resolve(&super::form::params(d), &app.session.prefs.flattener_presets)?; + // Nothing selected keeps the dialog open too. + if let Some(st) = app.session.active() { + fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?; + } + app.ui.eps_options = + Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect()); + app.ui.dialog = None; + let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); + io::export(app, Some("eps"), path, ¶ms) +} diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 14aff069..9f9b99d2 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -1,15 +1,15 @@ //! File → Export → Export As…: the format and Use Artboards (All or a range: one file per //! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG -//! Options, Text Export Options, DXF Options, and Save PDF for artboards as pages), else the file -//! is written. DWG is listed, greyed out, with what to use instead. +//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages), +//! else the file is written. DWG is listed, greyed out, with what to use instead. use std::sync::LazyLock; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; -use super::{DialogSpec, dxf_options, form, png_options, save_pdf, svg_options, text_export}; +use super::{DialogSpec, dxf_options, eps_options, form, png_options, save_pdf, svg_options, text_export}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -149,6 +149,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { dxf_options::open(app, ¶ms); return Ok(Value::Null); } + if f.id == "eps" { + eps_options::open(app, ¶ms); + return Ok(Value::Null); + } io::export(app, Some(f.id), Some(path), ¶ms) } } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index caf7720a..cdcf8293 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ pub mod confirm; mod document_setup; pub mod dxf_options; mod effect; +pub mod eps_options; pub mod expand; mod export_as; mod export_for_screens; @@ -221,6 +222,7 @@ registry! { Package: [package::KIND] => package::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, + EpsOptions: [eps_options::KIND] => eps_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -335,3 +337,6 @@ mod tests_raster_formats; mod tests_dxf; #[cfg(test)] mod tests_screens; + +#[cfg(test)] +mod tests_eps; diff --git a/crates/ui-egui/src/dialogs/tests_eps.rs b/crates/ui-egui/src/dialogs/tests_eps.rs new file mode 100644 index 00000000..3968d2d8 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_eps.rs @@ -0,0 +1,75 @@ +//! Export As → EPS Options, drawn headlessly. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +/// The PostScript of an EPS file. +fn ps(bytes: &[u8]) -> String { + String::from_utf8(vectorcraft_eps::sections(bytes).unwrap().0.to_vec()).unwrap() +} + +#[test] +fn export_as_eps_shows_eps_options_then_writes_and_remembers_them() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "eps"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + frame(&mut app); + set(&mut app, "useArtboards", json!(true)); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("epsOptions"), "EPS Options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("path"), d.str("previewFormat"), d.str("flattenerPreset")), + ("/out/Untitled-1.eps".into(), "tiffColor".into(), "Medium Resolution".into()) + ); + frame(&mut app); + // Every control draws: the black-and-white preview greys out Transparent / Opaque. + set(&mut app, "previewFormat", json!("tiffBw")); + set(&mut app, "level", json!(2)); + set(&mut app, "cmykPostScript", json!(false)); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("level"), "2", "a number reads as the level"); + set(&mut app, "flattenerPreset", json!("High Resolution")); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + // One file per artboard, named with an underscore. + assert_eq!(names(&written), ["/out/Untitled-1_Artboard-1.eps", "/out/Untitled-1_Artboard-2.eps"]); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2)); + let bytes = &written.borrow()[0].1; + let (text, tiff) = vectorcraft_eps::sections(bytes).unwrap(); + assert!(tiff.is_some() && String::from_utf8_lossy(text).contains("%%LanguageLevel: 2")); + assert!(ps(bytes).contains(" rg\n"), "RGB PostScript"); + // The next EPS Options start from these choices, but not from this export's path. + crate::menus::invoke(&mut app, "ui.epsOptionsDialog", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("previewFormat"), d.str("level"), d.str("flattenerPreset")), ("tiffBw".into(), "2".into(), "High Resolution".into())); + assert!(!d.bool("cmykPostScript") && !d.fields.contains_key("path")); + assert!(app.ui.eps_options.get("useArtboards").is_none()); +} + +#[test] +fn eps_options_take_saved_flattener_presets_and_refuse_bad_ones() { + let (mut app, written) = app(1); + app.run("flattener.presets.save", json!({"name": "Mine", "balance": 0})).unwrap(); + app.run("ui.epsOptionsDialog", json!({"path": "/out/a.eps", "flattenerPreset": "medium"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("flattenerPreset"), "Medium Resolution", "an id shows as its name"); + frame(&mut app); + set(&mut app, "flattenerPreset", json!("Nope")); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("preset"), "{e}"); + assert_eq!(kind(&app), Some("epsOptions")); + set(&mut app, "flattenerPreset", json!("Mine")); + set(&mut app, "previewFormat", json!("none")); + confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/out/a.eps"]); + assert!(written.borrow()[0].1.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"), "{:?}", &written.borrow()[0].1[..8]); +} + +#[test] +fn the_eps_options_dialog_needs_a_document() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + assert!(app.run("ui.epsOptionsDialog", json!({})).is_err()); + assert!(!crate::menus::enabled(&app, "ui.epsOptionsDialog")); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7ef9ede3..5960954b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -467,6 +467,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?, selectionOnly?} write Document Info's text report (document.info {format: \"text\"}) to path, else a picked file (the web downloads it) → {path}", ), + ( + "ui.epsOptionsDialog", + "EPS Options Dialog", + "", + "{path?, useArtboards?, range?, …document.exportEps options} open EPS Options (dialog `epsOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → EPS opens it too", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -844,6 +850,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::slices::open_options(app), "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), + "ui.epsOptionsDialog" if app.session.active().is_none() => Err("no document".into()), + "ui.epsOptionsDialog" => { + crate::dialogs::eps_options::open(app, p); + Ok(Value::Null) + } _ => return None, }; Some(r) @@ -1077,6 +1088,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()), "ui.placementOptionsDialog" => selected_image(app, |_| true), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), + "ui.epsOptionsDialog" => app.session.active().is_some(), _ => true, } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7dce851f..29dcd665 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -289,6 +289,9 @@ pub struct UiState { /// The DXF Options chosen last (`document.exportDxf` options; null: never used). #[serde(default)] pub dxf_options: Value, + /// The EPS Options chosen last (`document.exportEps` options; null: never used). + #[serde(default)] + pub eps_options: Value, } impl UiState { @@ -348,6 +351,7 @@ impl Default for UiState { place_link: true, dialog_file: None, dxf_options: Value::Null, + eps_options: Value::Null, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4244ce14..adbc832a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -331,3 +331,11 @@ The Slice tools: `ui.pointer` drags with the `slice` tool make a user slice (Shi undo step). With `sliceSelection` a click selects a slice (Shift toggles), a drag moves the selected slices, a drag on a selected user slice's handle resizes it, a double click opens `sliceOptions` and `ui.key` Delete deletes the selected slices. Hidden or locked slices (`view.slices.hide`, `view.slices.lock`) can't be picked. + +EPS Options: Export As… with the EPS format (`file.exportAs {format: "eps"}`, then the save dialog), or +`ui.epsOptionsDialog {path?, …document.exportEps options}` for agents, opens the `epsOptions` dialog. Its fields are +`document.export` params: `previewFormat` (`none`, `tiffBw`, `tiffColor`), `transparentPreview`, `overprints` +(`preserve`, `discard`), `flattenerPreset` (a built-in or saved preset's name), `includeLinkedFiles`, `thumbnails`, +`cmykPostScript`, `compatibleGradients`, `level` (`2`, `3`), plus `path` and the artboard choice (`useArtboards`, +`range`). `ui.dialog.confirm` checks them (a bad value or an unknown preset keeps the dialog open), remembers them for +the next EPS Options and writes the file(s), asking for a path when there is none. diff --git a/docs/mcp.md b/docs/mcp.md index e82d6d96..c1cc79e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1199,3 +1199,28 @@ format its options. The document remembers the last params: `document.exportSett {"name":"run_command","arguments":{"command":"document.exportForScreens","params":{"range":"1-2","formats":[{"format":"png","scale":"512w"},{"format":"jpg","quality":80,"scale":"2x"},{"format":"svg"}],"settings":{"png":{"background":"white"}}}}} {"name":"run_command","arguments":{"command":"document.exportSettings","params":{}}} ``` + +## EPS export + +`document.exportEps` (also `document.export` / `export` with format `eps`) writes Encapsulated PostScript: a DSC header +(`%!PS-Adobe-3.0 EPSF-3.0`, `%%BoundingBox`, `%%HiResBoundingBox`, `%%LanguageLevel`, spot colours as +`%%DocumentCustomColors`), then the page. Without `useArtboards` the file covers the visible art, its bounding box y up +from the first artboard's bottom-left corner; `useArtboards: true` (or `range`, `artboards`, `artboard`) writes one file +per artboard, `{stem}_{artboard}.eps`, each bounded by its artboard. `level: 3` (default) writes gradients as smooth +shadings and masks transparent image pixels out; `level: 2` writes gradients as bands of colour (as does +`compatibleGradients`) and transparent pixels white. Type is always glyph outlines, so no fonts are needed (`embedFonts` +is accepted); spot colours are Separation colour spaces, and overprinting fills and strokes overprint unless +`overprints: "discard"`. RGB documents are written in CMYK unless `cmykPostScript: false`. + +PostScript has no transparency: it is flattened first with `flattenerPreset` (`high`, `medium` (default), `low` or a +saved preset, see `flattener.presets.list`) and `flattener: {…}` options over it (those of +`object.flattenTransparency`); a warning says so. `previewFormat` adds a TIFF preview behind a binary header (`tiffColor` +(default), transparent unless `transparentPreview: false`, or 1-bit `tiffBw`; `none` writes plain PostScript), +`thumbnails` a PNG thumbnail in comments, and the native document always rides along in the trailer's comments (with +linked images whole when `includeLinkedFiles`), so a later EPS import can reopen the file as it was. Export for +Screens doesn't write EPS. + +```json +{"name":"run_command","arguments":{"command":"document.exportEps","params":{"path":"/tmp/logo.eps","level":2,"previewFormat":"tiffBw","flattenerPreset":"high"}}} +{"name":"export","arguments":{"path":"/tmp/art.eps","options":{"useArtboards":true,"cmykPostScript":false}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index a9c2abb9..f11edc97 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -57,6 +57,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("xtask", Class::Exempt), // Crates added later (append-only). ("cad", Class::Layer(3)), + ("eps", Class::Layer(3)), ]; /// Explicit orderings *within* a layer (earlier may be used by later).