diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 70029d84..fa0af535 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -85,8 +85,9 @@ impl Encoded { } /// Every file to write for the destination `path` (a path or a file name): the file itself - /// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images - /// beside it under their own names, then the image maps as `{image stem}.html` or `.map`. + /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard + /// among several, as Save's master file, is the file itself), then the linked images beside it + /// under their own names, then the image maps as `{image stem}.html` or `.map`. pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> { // Siblings keep the path's own separators (this runs on every platform and on the web). let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)]; @@ -94,15 +95,19 @@ impl Encoded { let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() { [(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))], files => { - let boards: Vec = files.iter().map(|(b, _)| b.unwrap_or(0)).collect(); + let boards: Vec = files.iter().filter_map(|(b, _)| *b).collect(); let file = std::path::Path::new(&path[dir.len()..]); let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); - super::artboard_file_names(doc, &boards) - .into_iter() - .zip(files) - .map(|(board, (_, bytes))| { - (sibling(&format!("{stem}{}{board}{ext}", self.joiner.unwrap_or("-"))), Cow::Borrowed(bytes.as_slice())) + let mut names = super::artboard_file_names(doc, &boards).into_iter(); + files + .iter() + .map(|(b, bytes)| { + let name = match b { + None => path.to_string(), + Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())), + }; + (name, Cow::Borrowed(bytes.as_slice())) }) .collect() } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7c6e43cf..dbef8d83 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -90,8 +90,8 @@ fn restore_or_import( let mut fallback = None; if let Some((intact, bytes)) = editing { let native = if intact { bytes() } else { None }; - match native.and_then(|b| vectorcraft_format::load(&b).ok()) { - Some(doc) => return Ok((doc, vec![], true)), + match native.and_then(|b| native_file(&b).ok()) { + Some((doc, _, warnings)) => return Ok((doc, warnings, true)), None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }), } } @@ -102,6 +102,14 @@ fn restore_or_import( Ok((doc, warnings, false)) } +/// A native file's document and details, the colour profiles it carries installed where missing +/// (with a warning for each that can't be used). +pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec)> { + let f = vectorcraft_format::load_file(bytes).map_err(err)?; + let warnings = super::native::install_profiles(f.profiles); + Ok((f.doc, f.info, warnings)) +} + /// Read a file of any readable format (`name`: its file name or path, for the extension and title; /// a path's folder is where an SVG's relative image links are found). pub fn load(name: &str, bytes: &[u8]) -> Result { @@ -123,9 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let (mut doc, warnings, restored) = match format.id { "vectorcraft" | "template" => { - let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?; + let (d, info, warnings) = native_file(bytes)?; converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; - (d, vec![], false) + (d, warnings, false) } "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 52b7587c..4fdf8083 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -43,6 +43,7 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; use load::err; pub(crate) use load::import_svg; +pub(crate) use load::native_file; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use native::with_compression_pref; @@ -360,7 +361,17 @@ pub const FORMATS: &[Format] = &[ options: svg::OPTIONS, }, Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, - reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), + Format { + id: "ai", + label: "PDF-compatible .ai", + extensions: &["ai"], + mime: "application/pdf", + read: true, + // Saved (Save As), not exported. + write: false, + raster: false, + options: native::AI_OPTIONS, + }, reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), Format { id: "png", diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index aef6dd8a..dda39386 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -1,8 +1,13 @@ //! The native format's options: compression, the version to write (older apps read older -//! versions) and an embedded preview. +//! 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::collections::BTreeMap; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::Document; use vectorcraft_format::{PREVIEW_MAX, SaveOptions}; @@ -29,33 +34,146 @@ pub const OPTIONS: &[FormatOption] = &[ 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, -., 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 options of a `.ai` file (a PDF carrying the native document; its PDF options come on +/// top, see document.exportPdf). +pub const AI_OPTIONS: &[FormatOption] = &[ + SEPARATE_ARTBOARDS, + SEPARATE_RANGE, + 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)", + }, ]; #[derive(Default, Deserialize)] -#[serde(default)] +#[serde(default, rename_all = "camelCase")] struct NativeOptions { compress: Option, version: Option, preview: bool, + include_linked: bool, + embed_profiles: Option, + pdf_compatible: bool, } -/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and -/// whether to embed a preview. -pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, 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)?; let mut so = SaveOptions::for_doc(doc); so.compress = o.compress.unwrap_or(false); so.version = o.version.unwrap_or(vectorcraft_format::VERSION); + so.include_linked = o.include_linked; + if o.embed_profiles.unwrap_or(true) { + so.profiles = embedded_profiles(doc); + } so.check().map_err(|e| bad(cmd, e.to_string()))?; - Ok((so, o.preview)) + 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> { - let (mut so, preview) = save_options(cmd, f, doc, p)?; - if preview { + let (mut so, extras) = save_options(cmd, f, doc, p)?; + if extras.preview { so.preview = preview_png(doc)?; } + if extras.pdf { + // Every artboard as PDF pages (no editing data: the file is the native document). + so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0); + } + vectorcraft_format::save_with(doc, &so).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> { + [&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>) -> Vec { + // 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() +} + +/// The native document a `.ai` file carries, with the options in `p` that apply to it +/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it). +pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible")); + } + let (mut so, _) = save_options(cmd, f, doc, &q)?; + so.pretty = false; vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string())) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 49abd21c..6aaf8739 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -197,11 +197,17 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false))) +} + +/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the +/// native document with its save options, whose pages may be left blank). +pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result>) -> Result<(Vec, Vec)> { let mut opts = options(cmd, doc, p)?; let mut warnings = vec![]; if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { - opts.native = Some(vectorcraft_format::save(doc, false)); + opts.native = Some(native()?); } else { opts.settings.preserve_editing = false; warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index cb6d284d..11ff3090 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -13,7 +13,8 @@ use vectorcraft_doc::Document; use super::super::*; use super::{ - Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file, + ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, + write_file, }; use crate::{DocState, Prefs}; @@ -198,10 +199,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General /// settings (`createLayers`…) and presets. fn reads_option(f: &Format, key: &str) -> bool { - match is_pdf(f) { - true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key), - false => f.options.iter().any(|o| o.name == key), - } + f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } /// Formats that carry the whole native document: they lose nothing, and record links relative to @@ -251,8 +249,62 @@ pub struct SaveJob { params: Value, /// The document when the job was made: what counts as saved once the file is written. snapshot: Arc, + /// The artboards also saved to files of their own (`separateArtboards`). + boards: Vec, } +/// The artboards a native or `.ai` save also writes to files of their own: with +/// `separateArtboards`, those `range` names (default all). +fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { + if !is_native(f) || !bool_or(p, "separateArtboards", false) { + return Ok(vec![]); + } + let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); + let n = doc.artboards.len(); + let picked = ArtboardPick { range, ..Default::default() }.resolve(n).map_err(|e| bad(cmd, e))?; + Ok(picked.unwrap_or_else(|| (0..n).collect())) +} + +/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file): +/// objects whose bounds touch the artboard, in their layers; resources are kept. +fn artboard_doc(doc: &Document, i: usize) -> Document { + let mut d = doc.clone(); + let Some(a) = doc.artboards.get(i) else { return d }; + let r = a.rect; + d.artboards = vec![a.clone()]; + // It opens fitted to its artboard, not at the master file's view. + d.last_view = None; + for layer in &mut d.layers { + keep_on(layer, r); + } + d +} + +/// Keep in layer `n` (and its sublayers) only the objects touching `r`. +fn keep_on(n: &mut Arc, r: vectorcraft_geom::Rect) { + let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0; + if let Some(children) = Arc::make_mut(n).children_mut() { + children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches)); + for c in children.iter_mut().filter(|c| c.is_layer()) { + keep_on(c, r); + } + } +} + +/// `doc` with empty layers: the pages of a `.ai` file saved without PDF content. +fn blank_pages(doc: &Document) -> Document { + let mut d = doc.clone(); + for layer in &mut d.layers { + if let Some(children) = Arc::make_mut(layer).children_mut() { + children.clear(); + } + } + d +} + +/// Why a `.ai` file saved without PDF content looks empty elsewhere. +const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; + /// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the /// document's own). Bad options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { @@ -285,12 +337,20 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result Result { - let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc); - let mut enc = if plan.format.id == "ai" { - let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?; + let mut enc = self.encode_one(&self.doc)?; + for &b in &self.boards { + let one = self.encode_one(&artboard_doc(&self.doc, b))?; + enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); + } + let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true); + let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string())); + enc.warnings.splice(0..0, notes); + Ok(enc) + } + + /// Encode one file of `doc` in the job's format. + fn encode_one(&self, doc: &Document) -> Result { + let (cmd, f) = (self.plan.mode.command(), self.plan.format); + let enc = if f.id == "ai" { + // The pages (blank without PDF content), carrying the native document. + let pages = doc.without_edit_modes(); + let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; + let native = || super::native::ai_native(cmd, f, doc, &self.params); + let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?; Encoded { warnings, ..Encoded::one(bytes) } } else { - encode_all(doc, plan.format.id, &self.params)? + encode_all(doc, f.id, &self.params)? }; if enc.files.len() != 1 { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } - enc.warnings.splice(0..0, fidelity_warning(plan.format)); Ok(enc) } - /// Encode the file and write it with `write` (the file, then the images an SVG links to, - /// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: - /// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`. + /// Encode the file and write it with `write` (the file, then its artboards' files and the + /// images an SVG links to, beside it). Without a path → `{dataBase64, bytes, format, name, + /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), + /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: + /// [path…], linked?: [path…]}`. pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; @@ -342,7 +421,11 @@ impl SaveJob { } let bytes = files.first().map_or(0, |f| f.1.len()); let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra); - if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) { + let (main, linked) = files.split_at(enc.files.len().min(files.len())); + if main.len() > 1 { + out["files"] = main.iter().map(|(p, _)| json!(p)).collect(); + } + if !linked.is_empty() { out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect(); } Ok(out) @@ -420,7 +503,9 @@ fn format_options(s: &mut Session, p: &Value) -> Result { const C: &str = "file.formatOptions"; let st = s.active(); let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft"); - let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; + let f = format(id) + .filter(|f| f.write || SAVE_FORMATS.contains(&f.id)) + .ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); @@ -443,7 +528,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f3451e23..c0a51183 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -735,7 +735,7 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R let bytes = store.read(&format!("{file}{DOC_EXT}")).map_err(EngineError::Other)?; let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default(); let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default(); - let mut doc = vectorcraft_format::load(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; + let (mut doc, _, warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?; let area = claim(s, store)?; let mut taken: Vec = copies_in(&store.list().map_err(EngineError::Other)?).into_iter().map(|(_, f)| f).collect(); taken.extend(open_copies(s)); @@ -761,7 +761,7 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path }), links.to_json())) + Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -823,7 +823,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7bc0c5b5..fd981a49 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1205,6 +1205,8 @@ mod tests_registration; #[cfg(test)] mod tests_save; #[cfg(test)] +mod tests_saveoptions; +#[cfg(test)] mod tests_scalestrokes; #[cfg(test)] mod tests_slices; diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests_saveoptions.rs new file mode 100644 index 00000000..f0adc432 --- /dev/null +++ b/crates/engine/src/tests_saveoptions.rs @@ -0,0 +1,206 @@ +//! Native and `.ai` save options: each artboard to a separate file (a master file too), Include +//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File and the `.ai` Use Compression. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::cms; + +use super::*; +use crate::tests_links::{Folder, png}; + +/// Three artboards (named A, B and C) with a rectangle on each. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap(); + for (i, name) in ["B", "C"].iter().enumerate() { + let x = 200.0 * (i + 1) as f64; + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap(); + } + for x in [10, 210, 410] { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap(); + } + s +} + +fn objects(d: &vectorcraft_doc::Document) -> usize { + d.layers.iter().map(|l| l.count() - 1).sum() +} + +fn bytes_of(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap() +} + +#[test] +fn three_artboards_give_four_files() { + let dir = Folder::new("separate"); + let mut s = three_boards(); + let path = dir.file("Poster.vectorcraft"); + let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [path.clone(), dir.file("Poster-A.vectorcraft"), dir.file("Poster-B.vectorcraft"), dir.file("Poster-C.vectorcraft")]); + // The master file has everything; each artboard's file that artboard and its art. + let master = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((master.artboards.len(), objects(&master)), (3, 3)); + for (f, name, x) in [(files[1], "A", 0.0), (files[2], "B", 200.0), (files[3], "C", 400.0)] { + let d = vectorcraft_format::load(&std::fs::read(f).unwrap()).unwrap(); + assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), d.artboards[0].rect.x0), (1, name, x)); + assert_eq!(objects(&d), 1, "{name}"); + } + // The document takes the master file; Save remembers the choice. + let st = s.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(path.as_str()), false)); + // A range: the master and two artboards. + let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "2-3"})).unwrap(); + assert_eq!(r["files"].as_array().unwrap().len(), 3); + assert!(s.execute("document.save", &json!({"path": path, "separateArtboards": true, "range": "4"})).is_err(), "no fourth artboard"); + // No path: the bytes of every file. + let r = s.execute("file.saveCopy", &json!({"separateArtboards": true})).unwrap(); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Poster copy.vectorcraft", "Poster copy-A.vectorcraft", "Poster copy-B.vectorcraft", "Poster copy-C.vectorcraft"]); + assert!(vectorcraft_format::sniff(&bytes_of(&r["files"][3]["dataBase64"]))); +} + +#[test] +fn ai_files_save_each_artboard_too_and_reopen_editable() { + let dir = Folder::new("separate-ai"); + let mut s = three_boards(); + let path = dir.file("Set.ai"); + let r = s.execute("file.saveAs", &json!({"path": path, "separateArtboards": true, "range": "1, 3"})).unwrap(); + assert_eq!(r["files"], json!([path, dir.file("Set-A.ai"), dir.file("Set-C.ai")])); + let r = s.execute("document.open", &json!({"path": dir.file("Set-C.ai")})).unwrap(); + assert_eq!(r["restored"], true); + let d = &s.doc().unwrap().doc; + assert_eq!((d.artboards.len(), d.artboards[0].name.as_str(), objects(d)), (1, "C", 1)); +} + +#[test] +fn a_linked_pngs_bytes_are_embedded() { + let dir = Folder::new("include-linked"); + let pic = dir.file("photo.png"); + let bytes = png(400, 300, [200, 30, 30]); + std::fs::write(&pic, &bytes).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + s.execute("file.place", &json!({"path": pic, "link": true})).unwrap(); + let images = |v: &Value| -> Value { + let text = bytes_of(&v["dataBase64"]); + serde_json::from_slice::(&text).unwrap()["images"].clone() + }; + // By default only the preview. + let plain = images(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()); + let (_, image) = plain.as_object().unwrap().iter().next().unwrap(); + assert_eq!(image["proxy"], true); + // Include Linked Files: the file's own bytes. + let r = s.execute("file.saveCopy", &json!({"compress": false, "includeLinked": true})).unwrap(); + let full = images(&r); + let (_, image) = full.as_object().unwrap().iter().next().unwrap(); + assert!(image.get("proxy").is_none()); + assert_eq!(vectorcraft_format::base64_decode(image["data"].as_str().unwrap()).unwrap(), bytes); + // With the file gone the document still shows the full pixels. + std::fs::remove_file(&pic).unwrap(); + let r = s.execute("document.open", &json!({"name": "copy.vectorcraft", "dataBase64": r["dataBase64"]})).unwrap(); + assert_eq!(r["missingLinks"].as_array().unwrap().len(), 1); + let d = &s.doc().unwrap().doc; + let blob = d.images.values().next().unwrap(); + assert_eq!(blob.bytes.as_slice(), bytes.as_slice()); + let mut linked = 0; + d.visit_images(|_, im| linked += usize::from(im.link.is_some())); + assert_eq!(linked, 1, "still linked"); +} + +#[test] +fn an_embedded_profile_is_restored() { + let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); + let icc = cms::icc_bytes(&builtin.name).unwrap(); + let name = format!("Studio RGB {}", std::process::id()); + cms::register_icc(&icc, Some(name.clone())).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone()); + let file = bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"]); + let v: Value = serde_json::from_slice(&file).unwrap(); + assert_eq!(vectorcraft_format::base64_decode(v["profiles"][&name]["data"].as_str().unwrap()).unwrap(), icc.to_vec()); + // Not embedded when asked not to; built-in profiles never are (they are everywhere). + let none: Value = + serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false, "embedProfiles": false})).unwrap()["dataBase64"])) + .unwrap(); + assert!(none.get("profiles").is_none()); + // Elsewhere (a profile name this machine lacks): opening installs it and the document keeps it. + let elsewhere = format!("Studio RGB elsewhere {}", std::process::id()); + let moved = String::from_utf8(file).unwrap().replace(&name, &elsewhere); + assert!(cms::profile(&elsewhere).is_none()); + let r = + s.execute("document.open", &json!({"name": "moved.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(moved.as_bytes())})).unwrap(); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb)); + assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str())); + // Damaged profile data opens the document with a warning. + let broken = format!("Studio RGB broken {}", std::process::id()); + let mut v: Value = serde_json::from_str(&moved.replace(&elsewhere, &broken)).unwrap(); + v["profiles"][&broken]["data"] = json!("AAAA"); + let r = s + .execute( + "document.open", + &json!({"name": "b.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&serde_json::to_vec(&v).unwrap())}), + ) + .unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains(&broken), "{r}"); +} + +#[test] +fn pdf_compatible_files_carry_a_pdf() { + let mut s = three_boards(); + let file = bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": true})).unwrap()["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).unwrap(); + assert!(pdf.starts_with(b"%PDF")); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none()); +} + +#[test] +fn ai_without_pdf_content_and_compression() { + let mut s = three_boards(); + let ai = |s: &mut Session, p: Value| -> (Vec, Value) { + let mut p = p; + p["format"] = json!("ai"); + let r = s.execute("file.saveCopy", &p).unwrap(); + (bytes_of(&r["dataBase64"]), r["warnings"].clone()) + }; + let (full, _) = ai(&mut s, json!({})); + let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false})); + assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}"); + // The pages are empty (an import of a page finds no art), the document comes back whole. + let page = |s: &mut Session, b: &[u8]| { + s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap(); + objects(&s.doc().unwrap().doc) + }; + assert!(page(&mut s, &full) > 0); + assert_eq!(page(&mut s, &blank), 0); + s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(&blank)})).unwrap(); + assert_eq!(objects(&s.doc().unwrap().doc), 3); + // Use Compression off writes the content uncompressed: a larger file. + let mut s = three_boards(); + let (packed, _) = ai(&mut s, json!({"compress": true})); + let (loose, _) = ai(&mut s, json!({"compress": false})); + assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len()); +} + +#[test] +fn format_options_list_the_save_options() { + let mut s = three_boards(); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + for (k, value) in [("separateArtboards", false), ("includeLinked", false), ("embedProfiles", true), ("pdfCompatible", true), ("compress", true)] { + assert_eq!(v["options"][k]["value"], value, "{k}"); + } + let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true)); + // Saved with them, a document remembers them for Save. + let dir = Folder::new("remember"); + let path = dir.file("r.vectorcraft"); + s.execute("document.save", &json!({"path": path, "includeLinked": true})).unwrap(); + let v = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(v["options"]["includeLinked"]["value"], true); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5c50878b..423b085b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,15 +5,20 @@ //! ```json //! { "format": "vectorcraft", "version": 3, "generator": "VectorCraft 0.1.0", //! "preview": { "mime": "image/png", "data": "" }, +//! "profiles": { "": { "mime": "application/vnd.iccprofile", "data": "" } }, +//! "pdf": { "mime": "application/pdf", "data": "" }, //! "document": { …vectorcraft_doc::Document… }, //! "images": { "": { "mime": "image/png", "data": "" } } } //! ``` //! It is lossless for everything in the document model; foreign data lives in `document.unknown` //! and document keys a newer version added are kept (`Document::extra`). Only the images the //! document uses are written. `preview` (optional) is a PNG of the first artboard, at most -//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. An image only linked images show -//! is saved as its low-resolution preview (`"proxy": true`); the engine reads the linked file again -//! when the document opens. +//! [`PREVIEW_MAX`] pixels on its longer side, for file browsers. `profiles` (optional, Embed ICC +//! Profiles) holds the ICC files of colour profiles the document is tagged with, for machines that +//! don't have them ([`load_file`] returns them). `pdf` (optional, Create PDF-Compatible File) is a +//! PDF of every artboard for apps that read PDF ([`pdf_content`]). An image only linked images +//! show is saved as its low-resolution preview (`"proxy": true`), unless saved with Include Linked +//! Files; the engine reads the linked file again when the document opens. //! //! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as //! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour @@ -95,6 +100,8 @@ struct File { document: Document, #[serde(default)] images: BTreeMap, + #[serde(default)] + profiles: BTreeMap, } /// How [`save_with`] writes a document. @@ -108,14 +115,23 @@ pub struct SaveOptions { pub version: u32, /// A PNG preview to embed (at most [`PREVIEW_MAX`] pixels on its longer side). pub preview: Option>, + /// Include Linked Files: linked images keep the file's own bytes, not just their preview. + pub include_linked: bool, + /// Embed ICC Profiles: ICC files by profile name. + pub profiles: BTreeMap>, + /// Create PDF-Compatible File: a PDF of the document to carry. + pub pdf: Option>, } impl Default for SaveOptions { fn default() -> Self { - Self { pretty: false, compress: false, version: VERSION, preview: None } + Self { pretty: false, compress: false, version: VERSION, preview: None, include_linked: false, profiles: BTreeMap::new(), pdf: None } } } +/// The MIME type of embedded ICC profiles. +pub const ICC_MIME: &str = "application/vnd.iccprofile"; + impl SaveOptions { /// Can a file be written this way (a version [`save_with`] writes, compressed only for readers /// that open compressed files)? @@ -177,21 +193,33 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError .iter() .filter(|(k, _)| used.contains(k.as_str())) .map(|(k, b)| { - let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) { + // Include Linked Files keeps the file's bytes whenever they are loaded. + let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); + let image = match b.proxy.as_ref().filter(|_| preview_only) { Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true }, None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false }, }; (k.as_str(), image) }) .collect(); - let size = body.len() + images.values().map(|i| i.data.len()).sum::() + 256; + let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), proxy: false }; + let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), blob(ICC_MIME, icc))).collect(); + let pdf = o.pdf.as_deref().map(|p| blob("application/pdf", p)); + let carried = profiles.values().chain(&pdf).map(|i| i.data.len()).sum::(); + let size = body.len() + images.values().map(|i| i.data.len()).sum::() + carried + 256; let mut w = Envelope { out: Vec::with_capacity(size), pretty }; // Older apps only know the name from before the rename. w.field("format", &if o.version < VERSION { LEGACY_EXTENSION } else { EXTENSION })?; w.field("version", &o.version)?; w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; if let Some(png) = &o.preview { - w.field("preview", &Image { mime: "image/png".into(), data: base64_encode(png), proxy: false })?; + w.field("preview", &blob("image/png", png))?; + } + if !profiles.is_empty() { + w.field("profiles", &profiles)?; + } + if let Some(pdf) = &pdf { + w.field("pdf", pdf)?; } w.raw("document", &body); w.field("images", &images)?; @@ -295,6 +323,15 @@ pub fn load(bytes: &[u8]) -> Result { load_info(bytes).map(|(doc, _)| doc) } +/// A native file read: its document, what it says about itself and the colour profiles it carries. +#[derive(Debug)] +pub struct NativeFile { + pub doc: Document, + pub info: FileInfo, + /// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out). + pub profiles: BTreeMap>, +} + /// What a native file says about itself besides its document. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FileInfo { @@ -313,6 +350,11 @@ impl FileInfo { /// [`load`], also telling the file's version and name. pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { + load_file(bytes).map(|f| (f.doc, f.info)) +} + +/// [`load`], with everything else the file carries. +pub fn load_file(bytes: &[u8]) -> Result { let text = unpack(bytes)?; let f: File = serde_json::from_slice(&text).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { @@ -338,18 +380,30 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { // Saved before v3: the assigned profiles move out of `unknown`. doc.migrate_color_profiles(); doc.fix_next_id(); - Ok((doc, FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION })) + let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect(); + Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) +} + +/// The members of a native file besides its document (which is skipped, not decoded). +#[derive(Deserialize)] +struct Head { + preview: Option, + pdf: Option, +} + +fn head(bytes: &[u8]) -> Option { + serde_json::from_slice(&unpack(bytes).ok()?).ok() } /// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file). pub fn preview(bytes: &[u8]) -> Option> { - #[derive(Deserialize)] - struct Head { - preview: Option, - } - let text = unpack(bytes).ok()?; - let head: Head = serde_json::from_slice(&text).ok()?; - base64_decode(&head.preview?.data) + base64_decode(&head(bytes)?.preview?.data) +} + +/// The PDF a native file saved with Create PDF-Compatible File carries (`None`: it has none, or +/// isn't a native file). +pub fn pdf_content(bytes: &[u8]) -> Option> { + base64_decode(&head(bytes)?.pdf?.data) } /// Does this look like a `.vectorcraft` file (compressed or not)? diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index ca591d1e..639af538 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -5,7 +5,7 @@ use proptest::prelude::*; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind}; -use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, preview, save, save_with, sniff}; +use vectorcraft_format::{SaveOptions, base64_decode, base64_encode, load, load_file, pdf_content, preview, save, save_with, sniff}; use vectorcraft_geom::Affine; use vectorcraft_testkit::fixtures; use vectorcraft_testkit::invariants::{check_document, check_native_roundtrip, check_native_roundtrip_exact, doc_json, json_approx_eq}; @@ -113,6 +113,26 @@ proptest! { check_document(&d).map_err(TestCaseError::fail)?; } } + + /// Mutating a file carrying ICC profiles and a PDF (save options) never panics the readers. + #[test] + fn load_mutated_file_with_profiles_never_panics(cut in 0usize..3000, flips in prop::collection::vec((0usize..3000, any::()), 0..8)) { + let o = SaveOptions { + profiles: [("Studio RGB".to_string(), vec![1, 2, 3]), ("Press".to_string(), vec![])].into(), + pdf: Some(b"%PDF-1.7".to_vec()), + ..SaveOptions::default() + }; + let mut bytes = save_with(&Document::new(100.0, 100.0), &o).unwrap(); + for (i, b) in flips { + let n = bytes.len(); + bytes[i % n] = b; + } + bytes.truncate(cut.min(bytes.len())); + let _ = pdf_content(&bytes); + if let Ok(f) = load_file(&bytes) { + check_document(&f.doc).map_err(TestCaseError::fail)?; + } + } } #[test] diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/save_options.rs new file mode 100644 index 00000000..766f4f67 --- /dev/null +++ b/crates/format/tests/save_options.rs @@ -0,0 +1,84 @@ +//! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the +//! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't +//! need them). +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::links::LinkInfo; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with}; +use vectorcraft_geom::Affine; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +/// A document showing image `k` linked to a file; the blob holds the file's bytes and a preview. +fn linked_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let im = ImageObject { + key: "k".into(), + width: 2, + height: 2, + xf: Affine::IDENTITY, + link: Some(LinkInfo::new("/art/photo.png")), + placement: Default::default(), + }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); + blob.proxy = Some(Arc::new(vec![9, 9])); + d.images.insert("k".into(), blob); + d +} + +#[test] +fn include_linked_files_keeps_the_files_bytes() { + let d = linked_doc(); + let plain = save_with(&d, &SaveOptions::default()).unwrap(); + let image = &json_of(&plain)["images"]["k"]; + assert_eq!(image["proxy"], true, "only the preview by default"); + let full = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + let image = &json_of(&full)["images"]["k"]; + assert!(image.get("proxy").is_none()); + let back = load(&full).unwrap(); + assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 4, 5, 6]); + assert!(!back.images["k"].is_proxy()); + // Only the preview loaded (the file was never found): the preview is all there is to write. + let mut missing = linked_doc(); + let preview = Arc::new(vec![9, 9]); + missing.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }); + let out = save_with(&missing, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + assert_eq!(json_of(&out)["images"]["k"]["proxy"], true); +} + +#[test] +fn embedded_profiles_and_pdf_round_trip() { + let mut d = Document::new(100.0, 100.0); + d.color_profiles.rgb = Some("Studio RGB".into()); + let o = SaveOptions { + profiles: [("Studio RGB".to_string(), vec![0, 1, 2, 3, 250])].into(), + pdf: Some(b"%PDF-1.7 fake".to_vec()), + compress: true, + ..SaveOptions::default() + }; + let bytes = save_with(&d, &o).unwrap(); + let f = load_file(&bytes).unwrap(); + assert_eq!(f.profiles["Studio RGB"], vec![0, 1, 2, 3, 250]); + assert_eq!(f.doc.color_profiles.rgb.as_deref(), Some("Studio RGB")); + assert_eq!(pdf_content(&bytes).unwrap(), b"%PDF-1.7 fake"); + // The plain file says what they are; a file without them has neither member. + let plain = save_with(&d, &SaveOptions { compress: false, ..o }).unwrap(); + let v = json_of(&plain); + assert_eq!((v["profiles"]["Studio RGB"]["mime"].as_str(), v["pdf"]["mime"].as_str()), (Some(ICC_MIME), Some("application/pdf"))); + let none = save_with(&d, &SaveOptions::default()).unwrap(); + assert!(json_of(&none).get("profiles").is_none() && pdf_content(&none).is_none()); + assert!(load_file(&none).unwrap().profiles.is_empty()); + // Damaged profile data is left out rather than failing the open. + let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1); + assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index e8f4b053..deae2061 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -5,11 +5,14 @@ //! //! Fields: `__action` (the save command it finishes), `__pick` (file name and format //! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). +//! Native and `.ai` files show their save options (VectorCraft Options after Save As): each +//! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, +//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. use std::sync::OnceLock; use serde_json::{Map, Value, json}; -use vectorcraft_engine::cmd::fileio::{self, Format, FormatOption, SAVE_FORMATS, SaveMode}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode}; use super::{DialogResult, DialogSpec, form}; use crate::state::Dialog; @@ -52,12 +55,58 @@ fn own_dialog(f: &Format) -> bool { matches!(f.id, "svg" | "svgz" | "pdf") } +/// Does `f` save each artboard to a separate file on request (native and `.ai` files)? Its +/// `range` then shows under that checkbox. +fn separates(f: &Format) -> bool { + f.options.iter().any(|o| o.name == "separateArtboards") +} + /// The options the dialog shows: arrays and objects are for agents, and a page range replaces the /// single artboard choice; none for a format with its own dialog. fn shown(f: &Format) -> impl Iterator { let range = f.options.iter().any(|o| o.name == "range"); + let separate = separates(f); let options = if own_dialog(f) { &[][..] } else { f.options }; - options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard"))) + options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard") || (separate && o.name == "range"))) +} + +/// An option's label: the save options as the reference app's options dialog names them, the +/// others from their names. +fn label(o: &FormatOption) -> String { + match o.name { + "separateArtboards" => "Save each artboard to a separate file".into(), + "includeLinked" => "Include Linked Files".into(), + "embedProfiles" => "Embed ICC Profiles".into(), + "pdfCompatible" => "Create PDF-Compatible File".into(), + "compress" => "Use Compression".into(), + _ => form::humanize(o.name), + } +} + +/// Every artboard (the All choice under Save each artboard to a separate file): `all` when set, +/// else no range given. +fn all_artboards(d: &Dialog) -> bool { + d.fields.get("all").and_then(Value::as_bool).unwrap_or_else(|| d.str("range").trim().is_empty()) +} + +/// All or Range, under Save each artboard to a separate file (enabled while it is on). +fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) { + ui.label(""); + ui.add_enabled_ui(d.bool("separateArtboards"), |ui| { + ui.horizontal(|ui| { + ui.add_space(22.0); + let mut all = all_artboards(d); + if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() { + d.fields.insert("all".into(), json!(all)); + } + let mut range = d.str("range"); + let field = egui::TextEdit::singleline(&mut range).desired_width(90.0).hint_text("1-3, 5"); + if ui.add_enabled(!all, field).changed() { + d.fields.insert("range".into(), json!(range)); + } + }); + }); + ui.end_row(); } /// The Save As format menu (labels in [`SAVE_FORMATS`] order). @@ -92,7 +141,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// One option: a checkbox, an artboard menu, a number or a text field. fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { let t = Tokens::get(ui.ctx()); - let label = form::humanize(o.name); + let label = label(o); let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); if o.ty == "boolean" { ui.label(""); @@ -101,6 +150,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma d.fields.insert(o.name.into(), json!(on)); } ui.end_row(); + if o.name == "separateArtboards" { + artboard_range(ui, d); + } return; } if o.ty == "string" { @@ -154,11 +206,20 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { return Err("name the file".into()); } let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path }; - let options: Map = f + let mut options: Map = f .options .iter() .filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone()))) .collect(); + if separates(f) { + if !d.bool("separateArtboards") || all_artboards(d) { + options.remove("range"); + } else { + // A bad range keeps the dialog open. + let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); + ArtboardPick { range: Some(d.str("range")), ..Default::default() }.resolve(n)?; + } + } app.ui.dialog = None; match SaveMode::of(&d.str("__action")) { Some(mode) if own_dialog(f) => io::ask_format_options(app, mode, f, &path), diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 04f1d8e0..62373fff 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -194,8 +194,8 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, +/// Options, the Save PDF dialog, or the save options dialog: native and `.ai` files on Save As +/// only), and so does Save As or Save a Copy to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: , /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { remember_view(app); @@ -222,14 +222,25 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo o.insert("path".into(), json!(with_save_extension(&picked, first.format))); } let chosen = plan(app, mode, &q)?; - // Native saves don't stop to ask: Use Compression decides, and options can be passed. - if ask && !chosen.format.options.is_empty() && !matches!(chosen.format.id, "vectorcraft" | "template") { + if ask && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); return ask_format_options(app, mode, chosen.format, &path); } write_plan(app, chosen) } +/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` +/// files only on Save As (the options dialog after the save panel); Save, a Copy and templates +/// reuse the remembered options or Use Compression. +fn asks_options(mode: SaveMode, f: &Format) -> bool { + !f.options.is_empty() + && match f.id { + "vectorcraft" | "ai" => mode == SaveMode::SaveAs, + "template" => false, + _ => true, + } +} + /// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? fn has_options(p: &Value) -> bool { ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad05e480..4afbbe19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,8 @@ mod tests_docsetup; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_nativeoptions; +#[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_paintchips; diff --git a/crates/ui-egui/src/tests_aisave.rs b/crates/ui-egui/src/tests_aisave.rs index df992bfd..b6af7a31 100644 --- a/crates/ui-egui/src/tests_aisave.rs +++ b/crates/ui-egui/src/tests_aisave.rs @@ -1,5 +1,6 @@ -//! Save As .ai in the app: a PDF-compatible file written without an options dialog, which reopens -//! as the document and saves as .ai again; Save a Copy as .ai leaves the document's path alone. +//! Save As .ai in the app: the options dialog, then a PDF-compatible file, which reopens as the +//! document and saves as .ai again (without asking); Save a Copy as .ai leaves the document's path +//! alone. use serde_json::{Value, json}; @@ -8,8 +9,13 @@ use crate::tests_svg::app; #[test] fn save_as_ai_writes_a_pdf_that_reopens_editable() { let (mut app, written) = app("/tmp/art.ai"); + // Save As asks for the .ai options first (M4.38), then writes. let r = app.run("file.saveAs", Value::Null).unwrap(); - assert_eq!(r["path"], "/tmp/art.ai", "no options dialog: {r}"); + assert_eq!(r["pending"], "saveOptions", "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("format").as_str(), d.str("path").as_str(), d.bool("pdfCompatible")), ("ai", "/tmp/art.ai", true)); + let r = crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(r["path"], "/tmp/art.ai", "{r}"); assert!(app.ui.dialog.is_none()); let ai = written.borrow()[0].1.clone(); assert!(ai.starts_with(b"%PDF")); diff --git a/crates/ui-egui/src/tests_nativeoptions.rs b/crates/ui-egui/src/tests_nativeoptions.rs new file mode 100644 index 00000000..aec94be4 --- /dev/null +++ b/crates/ui-egui/src/tests_nativeoptions.rs @@ -0,0 +1,103 @@ +//! VectorCraft Options after Save As: each artboard to a separate file (All or a range), Include +//! Linked Files, Embed ICC Profiles, Create PDF-Compatible File, Use Compression and Preview; the +//! web's Save As dialog shows them too. + +use serde_json::{Value, json}; + +use crate::tests_svg::app; +use crate::{dialogs, theme}; + +/// One headless frame of the dialog layer. +fn frame(app: &mut crate::VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +/// Add artboards B and C, a rectangle on each. +fn three_boards(app: &mut crate::VectorcraftApp) { + for (x, name) in [(200, "B"), (400, "C")] { + app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80, "name": name})).unwrap(); + app.run("shape.rectangle", json!({"x": x + 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } +} + +#[test] +fn save_as_native_asks_for_its_options_and_writes_every_artboard() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let r = app.run("file.saveAs", Value::Null).unwrap(); + assert_eq!(r["pending"], "saveOptions"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")), + ("vectorcraft", false, true, false) + ); + assert!(written.borrow().is_empty(), "nothing is written before OK"); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "drawing keeps it open"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(false)); + d.fields.insert("range".into(), json!("9")); + frame(&mut app); + assert!(dialogs::confirm(&mut app).is_err(), "a bad range keeps the dialog open"); + assert!(app.ui.dialog.is_some()); + app.ui.dialog.as_mut().unwrap().fields.insert("range".into(), json!("2-3")); + let r = dialogs::confirm(&mut app).unwrap(); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/tmp/doc.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"], "{r}"); + let st = app.session.active().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some("/tmp/doc.vectorcraft"), false)); + // Save writes them again with the same options, without asking. + app.run("shape.ellipse", json!({"x": 50, "y": 40, "width": 10, "height": 10})).unwrap(); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 6); + // All: every artboard. + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("separateArtboards"), "the options as last saved"); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 10); +} + +#[test] +fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { + let (mut app, written) = app("/tmp/plain.vectorcraft"); + // Save of an untitled document goes straight to the file, as before. + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 1); + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("pdfCompatible".into(), json!(true)); + d.fields.insert("compress".into(), json!(false)); + dialogs::confirm(&mut app).unwrap(); + let bytes = written.borrow()[1].1.clone(); + assert!(vectorcraft_format::pdf_content(&bytes).is_some_and(|p| p.starts_with(b"%PDF"))); + assert!(!vectorcraft_format::is_compressed(&bytes)); +} + +#[test] +fn the_web_save_as_dialog_shows_the_native_options() { + let written = crate::tests_svg::Written::default(); + let w = written.clone(); + let services = crate::Services { + download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + ..Default::default() + }; + let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + three_boards(&mut app); + app.run("file.saveAs", Value::Null).unwrap(); + frame(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("__pick") && d.fields.contains_key("separateArtboards")); + d.fields.insert("separateArtboards".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["Untitled-1.vectorcraft", "Untitled-1-Artboard-1.vectorcraft", "Untitled-1-B.vectorcraft", "Untitled-1-C.vectorcraft"]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 99b91dae..4b0e15a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -346,3 +346,9 @@ saved}]`, from `file.recovery.list`) and `discard`. `ui.dialog.confirm` restores each, opening " [Recovered]" documents), `discard: true` then confirm deletes them, `ui.dialog.cancel` keeps them for the next launch. While copies are written in the background `ui.inspect` lists them under `background` ("Saving recovery data for "). + +Native save options: File → Save As to a native or `.ai` file opens the `saveOptions` dialog after the save panel +(heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every +artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for +native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the +artboards' files. diff --git a/docs/mcp.md b/docs/mcp.md index 906c8d38..f6f40d91 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1291,3 +1291,26 @@ folder or browser storage the commands are disabled and say how to set one. {"name":"run_command","arguments":{"command":"file.recovery.save","params":{}}} {"name":"run_command","arguments":{"command":"file.recovery.restore","params":{"file":"1759650000-1/Poster-1"}}} ``` + +## Native save options + +`document.save` / `file.saveAs` / `file.saveCopy` to a native (`.vectorcraft`, `.vctemplate`) or `.ai` file take, flat +or in `options` (`file.formatOptions {format}` lists them with their values): + +- `separateArtboards: true` also writes each artboard of `range` (`"1-3, 5"`, default `"all"`) to + `-.` beside the file: that artboard and the art touching it. The result's `files` lists every + file written, the master file first (without a path: `files: [{name, dataBase64}]`). 3 artboards give 4 files. +- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. +- `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; + opening the file installs them where they are missing (a warning names one that can't be used). +- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files + (default true) with false write blank pages around the native document (smaller; other apps show empty pages). +- `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. + +File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel; Save, Save a Copy +and Save as Template reuse the options as last saved (or Use Compression). + +```json +{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}} +{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}} +```