mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 22:55:29 +00:00
Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in.
404 lines
20 KiB
Rust
404 lines
20 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 (with a central file of the whole
|
|
//! document when there are several and art shows), the preview rendered and the thumbnail made
|
|
//! from it, 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 vectorcraft_render::Rendered;
|
|
|
|
use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded};
|
|
use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param};
|
|
use super::{ArtboardPick, Encoded, FormatOption};
|
|
|
|
const C: &str = "document.export";
|
|
|
|
/// Most pixels along a side of a preview.
|
|
const MAX_PREVIEW: f64 = 2048.0;
|
|
/// Save needs a lightweight compatibility preview, not an uncompressed 16 MiB image beside
|
|
/// a small vector. TIFF format choices are unchanged; there is no public resolution option.
|
|
const SAVE_PREVIEW: f64 = 1024.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; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {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}. A key EPS doesn't take, and an unknown key inside flattener, are refused",
|
|
has_doc,
|
|
export_eps
|
|
)]
|
|
}
|
|
|
|
/// The EPS options `document.formats` lists.
|
|
pub(crate) 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, and with two or more a central <file>.eps of the whole document (when no art shows, Export writes none and Save writes it on the first artboard); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named (Save: the first artboard when no art shows)",
|
|
},
|
|
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;
|
|
// The writer draws colorized images in their ink.
|
|
flatten.keeps_colorize = true;
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// One file to write: its artboard, region and origin.
|
|
type Page = (Option<usize>, Rect, Point);
|
|
|
|
/// 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. With two or more
|
|
/// chosen artboards, a central file of the visible art comes first (none when no art shows).
|
|
/// `saving`: Save always writes the file it names, so with no visible art that file's region is
|
|
/// the first artboard, and the second value is true.
|
|
fn pages(doc: &Document, set: &Settings, saving: bool) -> Result<(Vec<Page>, bool)> {
|
|
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);
|
|
let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)));
|
|
// The visible art's bounds; for Save with no visible art, the first artboard (true).
|
|
let visible = || match vectorcraft_render::encode::art_bounds(doc) {
|
|
Some(bounds) => Some((bounds, false)),
|
|
None if saving => doc.artboards.first().map(|a| (a.rect, true)),
|
|
None => None,
|
|
};
|
|
match boards {
|
|
Some(boards) => {
|
|
let central = (boards.len() > 1).then(visible).flatten();
|
|
let each = 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))));
|
|
let pages = central.map(|(bounds, _)| Ok(art(bounds))).into_iter().chain(each).collect::<Result<_>>()?;
|
|
Ok((pages, central.is_some_and(|(_, blank)| blank)))
|
|
}
|
|
None => {
|
|
let missing = if saving { "the document has no artboard" } else { "nothing to export: the document has no visible art" };
|
|
let (bounds, blank) = visible().ok_or_else(|| bad(C, missing))?;
|
|
Ok((vec![art(bounds)], blank))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side.
|
|
fn fit(rect: Rect, max: f64) -> f64 {
|
|
let long = rect.width().max(rect.height());
|
|
if long > max { max / long } else { 1.0 }
|
|
}
|
|
|
|
/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white.
|
|
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Rendered> {
|
|
let scale = fit(rect, max);
|
|
vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?;
|
|
Ok(r.render_region(doc, rect, scale, !transparent))
|
|
}
|
|
|
|
/// `img` as the writer takes it: straight RGBA.
|
|
fn straight(img: &Rendered) -> Raster {
|
|
Raster { width: img.width, height: img.height, rgba: img.to_straight() }
|
|
}
|
|
|
|
/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a
|
|
/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white.
|
|
fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result<Raster> {
|
|
let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?;
|
|
let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels;
|
|
// Premultiplied over white: each channel gains the white the pixel's alpha leaves showing.
|
|
for px in rgba.as_chunks_mut::<4>().0 {
|
|
let clear = 255 - px[3];
|
|
for c in &mut px[..3] {
|
|
*c = c.saturating_add(clear);
|
|
}
|
|
px[3] = 255;
|
|
}
|
|
Ok(Raster { width, height, rgba })
|
|
}
|
|
|
|
/// 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 (after a central file of the whole document when there are several and art shows).
|
|
/// Transparency is flattened first (once for all of them); the previews show `doc` itself.
|
|
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
|
encode_document(doc, p, false)
|
|
}
|
|
|
|
/// Save writes the pages Export does (the visible art's bounds, or with Use Artboards the chosen
|
|
/// artboards), with a smaller preview. A single file, the one the document becomes, carries all
|
|
/// the native editing data; with no visible art its page is the first artboard.
|
|
pub(super) fn encode_save(doc: &Document, p: &Value) -> Result<Encoded> {
|
|
encode_document(doc, p, true)
|
|
}
|
|
|
|
fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
|
|
let set = parse(p)?;
|
|
if saving && bool_or(p, "selectedOnly", false) {
|
|
return Err(bad(C, "EPS Save writes all artwork: use document.export for selected-only EPS"));
|
|
}
|
|
let original = doc;
|
|
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
|
let doc = &*full;
|
|
// Include available bytes while retaining missing, relinkable objects in the attachment.
|
|
let editing = if set.include_linked {
|
|
let mut d = original.clone();
|
|
d.images = doc.images.clone();
|
|
Cow::Owned(d)
|
|
} else {
|
|
Cow::Borrowed(original)
|
|
};
|
|
let (pages, blank) = pages(doc, &set, saving)?;
|
|
if pages.is_empty() {
|
|
return Err(bad(C, "the document has no artboard"));
|
|
}
|
|
// The file Save writes alone becomes the document's own, so it carries every artboard.
|
|
let whole = saving && pages.len() == 1;
|
|
// The artboards' background colors, behind the art (the editing data keeps them as colors);
|
|
// a page on the first artboard because no art shows has that artboard's color.
|
|
let fills = pages.iter().filter_map(|(b, ..)| *b).chain(blank.then_some(0));
|
|
let filled = super::export::with_artboard_fills(doc, p, fills).map_err(|e| bad(C, e))?;
|
|
let doc = &*filled;
|
|
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
|
|
let expanded = expand_transparent_tile_images(doc, &set.flatten)?;
|
|
let source = expanded.as_ref().unwrap_or(doc);
|
|
let transparent = source.layers.iter().any(|l| set.flatten.shows_transparency(source, l));
|
|
let flat = flatten_expanded(source, &set.flatten)?;
|
|
let drawn = flat.as_ref().unwrap_or(source);
|
|
let mut enc = Encoded { warnings, joiner: Some("_"), ..Encoded::default() };
|
|
if transparent && (flat.is_some() || expanded.is_some()) {
|
|
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
|
}
|
|
let preview_limit = if saving { SAVE_PREVIEW } else { MAX_PREVIEW };
|
|
let created = vectorcraft_doc::metadata::output_time();
|
|
let mut renderer = vectorcraft_render::Renderer::new();
|
|
for (board, region, origin) in pages {
|
|
let rect = vectorcraft_eps::preview_rect(region, origin);
|
|
let shown = match set.eps.preview {
|
|
Preview::None => None,
|
|
kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?),
|
|
};
|
|
// The thumbnail is made from the preview; only a file without one renders it.
|
|
let thumbnail = match (set.thumbnails, &shown) {
|
|
(false, _) => None,
|
|
(true, Some(img)) => Some(thumbnail_of(img, rect)?),
|
|
(true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)),
|
|
};
|
|
let preview = shown.as_ref().map(straight);
|
|
drop(shown);
|
|
let o = EpsOptions {
|
|
region,
|
|
origin,
|
|
title: doc.info_title().to_owned(),
|
|
created,
|
|
native: Some(native(&editing, if whole { None } else { 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);
|
|
}
|
|
}
|
|
enc.add_setting_notes(out.setting_notes);
|
|
}
|
|
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
|
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,
|
|
})
|
|
}
|