mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
A native save was pretty-printed when the document had at most 2,000 layers and objects and its compact JSON was at most 1 MiB. Pretty printing puts every coordinate on its own indented line, so traced art with few paths and many anchors could pass that check and be written at about five times the size of its compact JSON. The 1 MiB limit now applies to the pretty-printed document, indented as the file holds it and in the form the save writes: version 1 anchors, and placed documents expanded for older versions or Include Linked Files. Documents whose pretty-printed JSON is over 1 MiB are saved as compact JSON, even when their compact JSON is under it. The 2,000-object rule and compressed saves are unchanged. docs/mcp.md describes the rule.
288 lines
12 KiB
Rust
288 lines
12 KiB
Rust
//! The native format's options: compression, the version to write (older apps read older
|
|
//! versions), an embedded preview, and the save options native and `.ai` files share: each
|
|
//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create
|
|
//! PDF-Compatible File.
|
|
|
|
use std::borrow::Cow;
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::cms;
|
|
use vectorcraft_doc::Document;
|
|
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
|
|
|
|
use super::super::*;
|
|
use super::{Format, FormatOption};
|
|
|
|
/// The options `document.save` and the native encoder read.
|
|
pub const OPTIONS: &[FormatOption] = &[
|
|
FormatOption {
|
|
name: "compress",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "gzip the file: smaller, not readable as text (saves default to Preferences → File Handling → Use Compression)",
|
|
},
|
|
FormatOption {
|
|
name: "version",
|
|
ty: "integer",
|
|
default: "4",
|
|
description: "the format version to write: 4 (current), or 3, 2 or 1 for older VectorCraft versions (1 and 2 are never compressed; newer features they don't know are lost there)",
|
|
},
|
|
FormatOption {
|
|
name: "preview",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
|
|
},
|
|
SEPARATE_ARTBOARDS,
|
|
SEPARATE_RANGE,
|
|
INCLUDE_LINKED,
|
|
EMBED_PROFILES,
|
|
FormatOption {
|
|
name: "pdfCompatible",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
|
|
},
|
|
];
|
|
|
|
const SEPARATE_ARTBOARDS: FormatOption = FormatOption {
|
|
name: "separateArtboards",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "also save each artboard (of range) to a file of its own beside this one, <name>-<artboard>.<ext>, holding that artboard and the art on it",
|
|
};
|
|
const SEPARATE_RANGE: FormatOption = FormatOption {
|
|
name: "range",
|
|
ty: "string",
|
|
default: "null",
|
|
description: "with separateArtboards: the artboards saved separately, 1-based such as \"1-3, 5\", or \"all\" (default)",
|
|
};
|
|
const INCLUDE_LINKED: FormatOption = FormatOption {
|
|
name: "includeLinked",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "keep the linked files' own pixels in the file (they stay linked), not just a preview: the document shows them in full where the files can't be found",
|
|
};
|
|
const EMBED_PROFILES: FormatOption = FormatOption {
|
|
name: "embedProfiles",
|
|
ty: "boolean",
|
|
default: "true",
|
|
description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed",
|
|
};
|
|
|
|
/// The save and export options of a `.ai` file (a PDF carrying the native document; its PDF
|
|
/// options come on top, see document.exportPdf). Saves always retain every artboard.
|
|
pub const AI_OPTIONS: &[FormatOption] = &[
|
|
super::ARTBOARD,
|
|
super::ARTBOARDS,
|
|
super::USE_ARTBOARDS,
|
|
FormatOption {
|
|
name: "range",
|
|
ty: "string",
|
|
default: "null",
|
|
description: "1-based artboards such as \"1-3, 5\", or \"all\": the PDF pages to export, or with separateArtboards the files to save separately",
|
|
},
|
|
SEPARATE_ARTBOARDS,
|
|
INCLUDE_LINKED,
|
|
EMBED_PROFILES,
|
|
FormatOption {
|
|
name: "pdfCompatible",
|
|
ty: "boolean",
|
|
default: "true",
|
|
description: "write the artwork as PDF pages that other apps show; false keeps only the native document (smaller, faster; other apps show empty pages)",
|
|
},
|
|
FormatOption {
|
|
name: "compress",
|
|
ty: "boolean",
|
|
default: "true",
|
|
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
|
|
},
|
|
FormatOption {
|
|
name: "aiVersion",
|
|
ty: "string",
|
|
default: "\"pdf\"",
|
|
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply; an error for a .ait template)",
|
|
},
|
|
FormatOption {
|
|
name: "transparentImages",
|
|
ty: "string",
|
|
default: "\"flatten\"",
|
|
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
|
|
},
|
|
FormatOption {
|
|
name: "hiddenObjects",
|
|
ty: "string",
|
|
default: "\"layer\"",
|
|
description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)",
|
|
},
|
|
FormatOption {
|
|
name: "outlineText",
|
|
ty: "boolean",
|
|
default: "false",
|
|
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines",
|
|
},
|
|
];
|
|
|
|
#[derive(Default, Deserialize)]
|
|
#[serde(default, rename_all = "camelCase")]
|
|
struct NativeOptions {
|
|
compress: Option<bool>,
|
|
version: Option<u32>,
|
|
preview: bool,
|
|
include_linked: bool,
|
|
embed_profiles: Option<bool>,
|
|
pdf_compatible: bool,
|
|
}
|
|
|
|
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
|
|
pub(super) struct Extras {
|
|
preview: bool,
|
|
pdf: bool,
|
|
}
|
|
|
|
/// The options in `p` for saving `doc`, checked, with the profiles to embed (the preview and PDF
|
|
/// are left to [`encode`]).
|
|
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
|
|
let o: NativeOptions = super::encode::options(f, p)?;
|
|
// Pretty or compact is decided by [`encode_to`] on the document it writes.
|
|
let mut so = SaveOptions {
|
|
compress: o.compress.unwrap_or(false),
|
|
version: o.version.unwrap_or(vectorcraft_format::VERSION),
|
|
include_linked: o.include_linked,
|
|
..SaveOptions::default()
|
|
};
|
|
if o.embed_profiles.unwrap_or(true) {
|
|
so.profiles = embedded_profiles(doc);
|
|
}
|
|
so.check().map_err(|e| bad(cmd, e.to_string()))?;
|
|
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
|
|
}
|
|
|
|
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
|
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
|
let mut out = Vec::new();
|
|
encode_to(cmd, f, doc, p, &mut out)?;
|
|
Ok(out)
|
|
}
|
|
|
|
/// [`encode`], written to `w` as it is made ([`vectorcraft_format::save_to`]: give it a buffered
|
|
/// writer).
|
|
pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: impl std::io::Write) -> Result<()> {
|
|
let (mut so, extras) = save_options(cmd, f, doc, p)?;
|
|
// Include Linked Files keeps placed documents' files; older versions get their art.
|
|
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
|
|
crate::cmd::place::document::full_documents(doc).0
|
|
} else {
|
|
std::borrow::Cow::Borrowed(doc)
|
|
};
|
|
if extras.preview {
|
|
so.preview = preview_png(&full)?;
|
|
}
|
|
if extras.pdf {
|
|
// Every artboard as PDF pages (no editing data: the file is the native document).
|
|
so.pdf = Some(super::pdf::encode(cmd, &full, &json!({ "range": "all", "preserveEditing": false }))?.0);
|
|
}
|
|
let native = match full {
|
|
Cow::Owned(full) if so.include_linked && so.version >= vectorcraft_format::VERSION => {
|
|
// Include available bytes without replacing missing, relinkable placed objects.
|
|
let mut original = doc.clone();
|
|
original.images = full.images;
|
|
Cow::Owned(original)
|
|
}
|
|
other => other,
|
|
};
|
|
so.pretty = so.pretty_fits(&native);
|
|
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
|
|
}
|
|
|
|
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
|
|
/// ones are there wherever VectorCraft runs).
|
|
fn embedded_profiles(doc: &Document) -> BTreeMap<String, Vec<u8>> {
|
|
[&doc.color_profiles.rgb, &doc.color_profiles.cmyk]
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|name| cms::profile(name).filter(|p| !p.builtin))
|
|
.filter_map(|p| Some((p.name.clone(), cms::icc_bytes(&p.name).ok()?.to_vec())))
|
|
.collect()
|
|
}
|
|
|
|
/// Most profiles read from one file.
|
|
const MAX_PROFILES: usize = 8;
|
|
|
|
/// Make the profiles a file carries available (those not installed here), named as in the file →
|
|
/// a warning per profile that can't be used.
|
|
pub(super) fn install_profiles(profiles: BTreeMap<String, Vec<u8>>) -> Vec<String> {
|
|
// A document is tagged with an RGB and a CMYK profile at most: more is junk.
|
|
profiles
|
|
.into_iter()
|
|
.take(MAX_PROFILES)
|
|
.filter(|(name, _)| cms::profile(name).is_none())
|
|
.filter_map(|(name, icc)| {
|
|
cms::register_icc(&icc, Some(name.clone())).err().map(|e| format!("the colour profile “{name}” the file carries can't be used: {e}"))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Check AI options even when chosen PDF pages omit the native editing attachment.
|
|
pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<SaveOptions> {
|
|
let mut q = p.clone();
|
|
if let Some(o) = q.as_object_mut() {
|
|
// AI carries the current native format without a separate preview. Its compression
|
|
// and PDF-content flags are still validated as booleans.
|
|
o.retain(|k, _| !matches!(k.as_str(), "version" | "preview"));
|
|
if o.get("pdfCompatible").is_some_and(Value::is_null) {
|
|
o.remove("pdfCompatible");
|
|
}
|
|
}
|
|
let (mut so, _) = save_options(cmd, f, doc, &q)?;
|
|
so.pretty = false;
|
|
so.compress = false; // The PDF compresses its editing attachment.
|
|
Ok(so)
|
|
}
|
|
|
|
/// The original native document carried by an AI file, with validated save options.
|
|
pub(super) fn ai_native(cmd: &str, doc: &Document, so: &SaveOptions) -> Result<Vec<u8>> {
|
|
let native = if so.include_linked {
|
|
match crate::cmd::place::document::full_documents(doc).0 {
|
|
Cow::Borrowed(d) => Cow::Borrowed(d),
|
|
Cow::Owned(full) => {
|
|
// Include available files, retaining placed objects even when their files
|
|
// are missing. Only drawn output substitutes unlinked preview images.
|
|
let mut original = doc.clone();
|
|
original.images = full.images;
|
|
Cow::Owned(original)
|
|
}
|
|
}
|
|
} else {
|
|
Cow::Borrowed(doc)
|
|
};
|
|
vectorcraft_format::save_with(&native, so).map_err(|e| bad(cmd, e.to_string()))
|
|
}
|
|
|
|
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
|
/// to show).
|
|
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
|
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
|
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
|
if vectorcraft_render::raster_size(r, scale).is_err() {
|
|
return Ok(None);
|
|
}
|
|
vectorcraft_render::Renderer::new().render_region(doc, r, scale, false).to_png().map(Some).map_err(EngineError::Other)
|
|
}
|
|
|
|
/// `p` with `compress` from the Use Compression preference when it doesn't say (saves only;
|
|
/// exports and serializing write what they're told).
|
|
pub fn with_compression_pref(prefs: &crate::Prefs, p: &Value) -> Value {
|
|
let mut q = if p.is_object() { p.clone() } else { Value::Object(Default::default()) };
|
|
if let Some(o) = q.as_object_mut()
|
|
&& o.get("compress").is_none_or(Value::is_null)
|
|
// Older versions can't read compressed files: those saves stay plain.
|
|
&& o.get("version").and_then(Value::as_u64).is_none_or(|v| v >= u64::from(vectorcraft_format::COMPRESSED_SINCE))
|
|
{
|
|
o.insert("compress".into(), Value::Bool(prefs.use_compression));
|
|
}
|
|
q
|
|
}
|