Merge pull request #103 from storytold/pr/m4-eps-export
M4.76: EPS export
This commit is contained in:
commit
e2e36f221b
|
|
@ -4176,6 +4176,7 @@ dependencies = [
|
||||||
"vectorcraft-cad",
|
"vectorcraft-cad",
|
||||||
"vectorcraft-color",
|
"vectorcraft-color",
|
||||||
"vectorcraft-doc",
|
"vectorcraft-doc",
|
||||||
|
"vectorcraft-eps",
|
||||||
"vectorcraft-format",
|
"vectorcraft-format",
|
||||||
"vectorcraft-geom",
|
"vectorcraft-geom",
|
||||||
"vectorcraft-pathops",
|
"vectorcraft-pathops",
|
||||||
|
|
@ -4188,6 +4189,22 @@ dependencies = [
|
||||||
"vectorcraft-trace",
|
"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]]
|
[[package]]
|
||||||
name = "vectorcraft-format"
|
name = "vectorcraft-format"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -4387,6 +4404,7 @@ dependencies = [
|
||||||
"vectorcraft-doc",
|
"vectorcraft-doc",
|
||||||
"vectorcraft-effects",
|
"vectorcraft-effects",
|
||||||
"vectorcraft-engine",
|
"vectorcraft-engine",
|
||||||
|
"vectorcraft-eps",
|
||||||
"vectorcraft-format",
|
"vectorcraft-format",
|
||||||
"vectorcraft-geom",
|
"vectorcraft-geom",
|
||||||
"vectorcraft-pdf",
|
"vectorcraft-pdf",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ vectorcraft-ui-egui = { path = "crates/ui-egui" }
|
||||||
vectorcraft-mcp = { path = "crates/mcp" }
|
vectorcraft-mcp = { path = "crates/mcp" }
|
||||||
vectorcraft-testkit = { path = "crates/testkit" }
|
vectorcraft-testkit = { path = "crates/testkit" }
|
||||||
vectorcraft-cad = { path = "crates/cad" }
|
vectorcraft-cad = { path = "crates/cad" }
|
||||||
|
vectorcraft-eps = { path = "crates/eps" }
|
||||||
|
|
||||||
# foundation
|
# foundation
|
||||||
kurbo = { version = "0.13", features = ["serde"] }
|
kurbo = { version = "0.13", features = ["serde"] }
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ vectorcraft-svg = { workspace = true }
|
||||||
vectorcraft-format = { workspace = true }
|
vectorcraft-format = { workspace = true }
|
||||||
vectorcraft-pdf = { workspace = true }
|
vectorcraft-pdf = { workspace = true }
|
||||||
vectorcraft-cad = { workspace = true }
|
vectorcraft-cad = { workspace = true }
|
||||||
|
vectorcraft-eps = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
flate2 = { workspace = true }
|
flate2 = { workspace = true }
|
||||||
kurbo = { workspace = true }
|
kurbo = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ pub struct Encoded {
|
||||||
pub maps: Vec<imagemap::Map>,
|
pub maps: Vec<imagemap::Map>,
|
||||||
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
|
/// 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 {
|
impl Encoded {
|
||||||
|
|
@ -98,7 +101,9 @@ impl Encoded {
|
||||||
super::artboard_file_names(doc, &boards)
|
super::artboard_file_names(doc, &boards)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(files)
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -313,6 +318,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||||
"txt" => super::text::encode(doc, p)?,
|
"txt" => super::text::encode(doc, p)?,
|
||||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
"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),
|
"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" => {
|
"pdf" => {
|
||||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||||
|
|
|
||||||
|
|
@ -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<CommandSpec> {
|
||||||
|
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), <file>_<artboard>.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<bool>,
|
||||||
|
level: Option<Value>,
|
||||||
|
preview_format: Option<String>,
|
||||||
|
transparent_preview: Option<bool>,
|
||||||
|
overprints: Option<String>,
|
||||||
|
flattener_preset: Option<String>,
|
||||||
|
flattener: Option<Value>,
|
||||||
|
include_linked_files: Option<bool>,
|
||||||
|
thumbnails: Option<bool>,
|
||||||
|
cmyk_post_script: Option<bool>,
|
||||||
|
compatible_gradients: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An option read by `read`, or an error listing what it may be.
|
||||||
|
fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, default: T, allowed: &str) -> Result<T> {
|
||||||
|
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<FlattenOptions> {
|
||||||
|
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<Params> {
|
||||||
|
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<Settings> {
|
||||||
|
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<Value, String> {
|
||||||
|
let run = || -> Result<Value> {
|
||||||
|
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<Option<Value>> {
|
||||||
|
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<Vec<(Option<usize>, 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<Raster> {
|
||||||
|
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<usize>, include_linked: bool) -> Vec<u8> {
|
||||||
|
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<Encoded> {
|
||||||
|
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<Value> {
|
||||||
|
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<Cow<'a, Value>> {
|
||||||
|
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
|
||||||
|
Some(q) => Cow::Owned(q),
|
||||||
|
None => p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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<Value> {
|
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
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 p = &*expanded;
|
||||||
let doc = &*source(s, f, p, "document.serialize")?;
|
let doc = &*source(s, f, p, "document.serialize")?;
|
||||||
let enc = encode_all(doc, f.id, p)?;
|
let enc = encode_all(doc, f.id, p)?;
|
||||||
|
|
@ -46,7 +46,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let path = str_param(p, "path");
|
let path = str_param(p, "path");
|
||||||
let f = writable("document.export", str_param(p, "format"), 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 p = &*expanded;
|
||||||
let doc = &*source(s, f, p, "document.export")?;
|
let doc = &*source(s, f, p, "document.export")?;
|
||||||
let enc = encode_all(doc, f.id, p)?;
|
let enc = encode_all(doc, f.id, p)?;
|
||||||
|
|
@ -101,6 +101,13 @@ pub(super) fn without_artboards(p: &Value) -> Value {
|
||||||
q
|
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<Cow<'a, Value>> {
|
||||||
|
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
|
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||||
/// with `selectionOnly` the selected objects alone.
|
/// with `selectionOnly` the selected objects alone.
|
||||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
|
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
|
||||||
|
|
@ -135,7 +142,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "document.exportSelection";
|
const C: &str = "document.exportSelection";
|
||||||
let path = str_param(p, "path");
|
let path = str_param(p, "path");
|
||||||
let f = writable(C, str_param(p, "format"), 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 p = &*expanded;
|
||||||
let (d, bounds) = selection(s, C)?;
|
let (d, bounds) = selection(s, C)?;
|
||||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
//! - `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`.
|
//! - `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).
|
//! - `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`],
|
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||||
//! agents query `document.formats`.
|
//! agents query `document.formats`.
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
mod batch;
|
mod batch;
|
||||||
pub mod dxf;
|
pub mod dxf;
|
||||||
mod encode;
|
mod encode;
|
||||||
|
pub mod eps;
|
||||||
mod export;
|
mod export;
|
||||||
mod imagemap;
|
mod imagemap;
|
||||||
mod load;
|
mod load;
|
||||||
|
|
@ -75,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Serialize Document",
|
"Serialize Document",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
export::serialize
|
export::serialize
|
||||||
),
|
),
|
||||||
|
|
@ -84,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Export Document",
|
"Export Document",
|
||||||
[],
|
[],
|
||||||
None,
|
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 <stem>.html / <stem>.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 <stem>.html / <stem>.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,
|
has_doc,
|
||||||
export::export
|
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: "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: "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: "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).
|
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||||
|
|
@ -697,7 +709,11 @@ mod tests_text;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_dxf;
|
mod tests_dxf;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_svgenc;
|
mod tests_svgenc;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_svgimport;
|
mod tests_svgimport;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_eps;
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ impl Row {
|
||||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
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)?;
|
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"));
|
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
|
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
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::<Vec<_>>();
|
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||||
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()));
|
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();
|
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||||
|
|
|
||||||
|
|
@ -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<u8> {
|
||||||
|
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<String> {
|
||||||
|
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<u32> {
|
||||||
|
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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
@ -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<Option<Document>> {
|
||||||
|
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 ----------
|
// ---------- apply ----------
|
||||||
|
|
||||||
fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec<NodeId>, usize, usize)> {
|
fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(links::specs());
|
v.extend(links::specs());
|
||||||
v.extend(pdfcmds::specs());
|
v.extend(pdfcmds::specs());
|
||||||
v.extend(fileio::dxf::specs());
|
v.extend(fileio::dxf::specs());
|
||||||
|
v.extend(fileio::eps::specs());
|
||||||
v.extend(package::specs());
|
v.extend(package::specs());
|
||||||
v.extend(slices::specs());
|
v.extend(slices::specs());
|
||||||
v
|
v
|
||||||
|
|
|
||||||
|
|
@ -827,3 +827,31 @@ proptest! {
|
||||||
survive("mutated native file", || vectorcraft_engine::cmd::fileio::load("x.vectorcraft", &bytes).ok().map(|l| l.doc))?;
|
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<u8> {
|
||||||
|
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::<u8>()), 0..12)) {
|
||||||
|
static SAMPLE: std::sync::OnceLock<Vec<u8>> = 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()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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<Self> {
|
||||||
|
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> {
|
||||||
|
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> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64>,
|
||||||
|
/// The native document the file carries, for reopening it as it was.
|
||||||
|
pub native: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A written file.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct EpsOutput {
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
/// What was approximated or left out.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<EpsOutput, String> {
|
||||||
|
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: <name>`)
|
||||||
|
/// 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<Vec<u8>> {
|
||||||
|
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<Vec<u8>, 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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
read_data(sections(bytes)?.0, THUMBNAIL)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
@ -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<Vec<u8>> {
|
||||||
|
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<u8> = 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<u32> {
|
||||||
|
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<u8>) {
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<vectorcraft_color::cms::Cms>,
|
||||||
|
body: String,
|
||||||
|
setup: String,
|
||||||
|
/// Spot colour spaces defined in the setup (`VCsp<i>`): swatch name and CMYK equivalent.
|
||||||
|
spots: Vec<(String, [f32; 4])>,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||||
|
/// Image keys → what draws them (`None`: left out).
|
||||||
|
images: HashMap<String, Option<Arc<String>>>,
|
||||||
|
/// 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<usize> {
|
||||||
|
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<Node>]) {
|
||||||
|
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::<Vec<_>>());
|
||||||
|
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<f64> = 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<u8> = 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<image::RgbaImage> {
|
||||||
|
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<String> {
|
||||||
|
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<String> = 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)
|
||||||
|
}
|
||||||
|
|
@ -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<Node>) -> 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<String> {
|
||||||
|
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<usize> = 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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> = (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<u8> = (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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<u8>, 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<u16> = 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<Vec<u8>, 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)
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,10 @@ fn export_tool_lists_engine_formats_and_options() {
|
||||||
let tools = tool_definitions();
|
let tools = tool_definitions();
|
||||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||||
let props = &export["inputSchema"]["properties"];
|
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"] {
|
for k in ["artboard", "range", "options"] {
|
||||||
assert!(props.get(k).is_some(), "export takes {k}");
|
assert!(props.get(k).is_some(), "export takes {k}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ vectorcraft-svg = { workspace = true }
|
||||||
vectorcraft-effects = { workspace = true }
|
vectorcraft-effects = { workspace = true }
|
||||||
vectorcraft-pdf = { workspace = true }
|
vectorcraft-pdf = { workspace = true }
|
||||||
vectorcraft-cad = { workspace = true }
|
vectorcraft-cad = { workspace = true }
|
||||||
|
vectorcraft-eps = { workspace = true }
|
||||||
vectorcraft-format = { workspace = true }
|
vectorcraft-format = { workspace = true }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
egui_extras = { workspace = true }
|
egui_extras = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -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<String, Value> {
|
||||||
|
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<Value, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
|
//! 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
|
//! 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
|
//! 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
|
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages),
|
||||||
//! is written. DWG is listed, greyed out, with what to use instead.
|
//! else the file is written. DWG is listed, greyed out, with what to use instead.
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
|
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::state::Dialog;
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
use crate::{VectorcraftApp, io, widgets};
|
use crate::{VectorcraftApp, io, widgets};
|
||||||
|
|
@ -149,6 +149,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||||
dxf_options::open(app, ¶ms);
|
dxf_options::open(app, ¶ms);
|
||||||
return Ok(Value::Null);
|
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)
|
io::export(app, Some(f.id), Some(path), ¶ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod confirm;
|
||||||
mod document_setup;
|
mod document_setup;
|
||||||
pub mod dxf_options;
|
pub mod dxf_options;
|
||||||
mod effect;
|
mod effect;
|
||||||
|
pub mod eps_options;
|
||||||
pub mod expand;
|
pub mod expand;
|
||||||
mod export_as;
|
mod export_as;
|
||||||
mod export_for_screens;
|
mod export_for_screens;
|
||||||
|
|
@ -221,6 +222,7 @@ registry! {
|
||||||
Package: [package::KIND] => package::SPEC,
|
Package: [package::KIND] => package::SPEC,
|
||||||
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
|
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
|
||||||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||||
|
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||||
|
|
@ -335,3 +337,6 @@ mod tests_raster_formats;
|
||||||
mod tests_dxf;
|
mod tests_dxf;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_screens;
|
mod tests_screens;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_eps;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -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}",
|
"{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).
|
/// 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<R
|
||||||
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
|
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
|
||||||
"object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app),
|
"object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => 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),
|
"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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(r)
|
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()),
|
"links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()),
|
||||||
"ui.placementOptionsDialog" => selected_image(app, |_| true),
|
"ui.placementOptionsDialog" => selected_image(app, |_| true),
|
||||||
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
"ui.packageDialog" | "docInfo.save" => app.session.active().is_some(),
|
||||||
|
"ui.epsOptionsDialog" => app.session.active().is_some(),
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,9 @@ pub struct UiState {
|
||||||
/// The DXF Options chosen last (`document.exportDxf` options; null: never used).
|
/// The DXF Options chosen last (`document.exportDxf` options; null: never used).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dxf_options: Value,
|
pub dxf_options: Value,
|
||||||
|
/// The EPS Options chosen last (`document.exportEps` options; null: never used).
|
||||||
|
#[serde(default)]
|
||||||
|
pub eps_options: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiState {
|
impl UiState {
|
||||||
|
|
@ -348,6 +351,7 @@ impl Default for UiState {
|
||||||
place_link: true,
|
place_link: true,
|
||||||
dialog_file: None,
|
dialog_file: None,
|
||||||
dxf_options: Value::Null,
|
dxf_options: Value::Null,
|
||||||
|
eps_options: Value::Null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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.
|
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.
|
||||||
|
|
|
||||||
25
docs/mcp.md
25
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.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":{}}}
|
{"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}}}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||||
("xtask", Class::Exempt),
|
("xtask", Class::Exempt),
|
||||||
// Crates added later (append-only).
|
// Crates added later (append-only).
|
||||||
("cad", Class::Layer(3)),
|
("cad", Class::Layer(3)),
|
||||||
|
("eps", Class::Layer(3)),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue