mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3 too) without transparency: a copy of the document is flattened first with flattenerPreset (stored with PDF presets; empty is High Resolution) and the flattener options over it, see-through image pixels counting as transparency and rasterized areas clipped to their regions so no soft mask is needed. Page groups are left out and the writer refuses a file that would still have transparency. PDF/X-1a and PDF/X-3 share this path (their version is now PDF 1.3; 1.4 is still accepted and written as 1.3), and with it the preset, the header and metadata code and the transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4 (revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener preset under Advanced. Co-Authored-By: Claude Opus 5.5 <[email protected]>
304 lines
14 KiB
Rust
304 lines
14 KiB
Rust
//! 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> {
|
|
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), 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(super::dxf::text);
|
|
let eps = EpsOptions {
|
|
level: choice("level", level, Level::from_id, d.level, "2 or 3")?,
|
|
preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?,
|
|
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(C, 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(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
|
match str_param(p, "flattenerPreset") {
|
|
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
|
|
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
|
|
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, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
|
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
|
|
Some(q) => Cow::Owned(q),
|
|
None => p,
|
|
})
|
|
}
|