From d61a7abc960ebc7302802bf91bde96128198beab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:25:34 +0700 Subject: [PATCH] Save: include a PDF in new native files by default Squashed from #5320 onto main. --- apps/vectorcraft-cli/src/main.rs | 7 +- apps/vectorcraft-cli/src/perf.rs | 106 ++++++++++++- crates/engine/src/cmd/fileio/load.rs | 11 +- crates/engine/src/cmd/fileio/native.rs | 16 +- crates/engine/src/cmd/fileio/pdf.rs | 28 +++- crates/engine/src/cmd/fileio/save.rs | 35 +++- crates/engine/src/cmd/fileio/tests.rs | 15 +- crates/engine/src/cmd/fileio/tests_svg.rs | 6 +- crates/engine/src/cmd/recovery.rs | 14 +- crates/engine/src/tests/nativefile.rs | 6 +- crates/engine/src/tests/recovery.rs | 51 ++++++ crates/engine/src/tests/saveoptions.rs | 143 ++++++++++++++++- crates/engine/tests/plugin_export.rs | 15 +- crates/format/src/lib.rs | 149 +++++++++++++++++- .../ui-egui/src/dialogs/save_options/tests.rs | 1 + crates/ui-egui/src/tests/nativeoptions.rs | 2 +- docs/mcp.md | 6 +- docs/plugins.md | 4 +- 18 files changed, 579 insertions(+), 36 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2d8df4592..3a8f18cd8 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -128,12 +128,17 @@ USAGE: Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. - vectorcraft-cli perf [--paths N] [--deep] + vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]] Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded, unless the report says the machine is busy. --deep also times work with no budget yet and prints memory use (the working set on Windows), with the peak so far on Windows and Linux. + --native-io instead measures the application's native save/open commands (one warmup, + five samples, no disk I/O), including preview, PDF, compression and API base64 costs. + It reports the default and explicit PDF opt-out, with and without preview/compression; + --artboards repeats the same artwork + on 1 to 4 overlapping artboards. This mode allows at most 50000 paths. "; /// The usage text plus the formats `document.open` reads and `document.export` writes. diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3ff82512d..e533d00b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { v.get(v.len() / 2).copied().unwrap_or(0.0) } +/// Exercise the application's native save/open commands, including previews, PDF output, +/// compression and API base64 conversion. Session setup and validation are outside timing. +/// Five measured runs follow one warmup; no filesystem or GPU window is involved. +fn native_io(doc: &Document) -> Result<(), String> { + for (label, compatible, preview) in [ + ("default options", None, false), + ("PDF disabled", Some(false), false), + ("preview+gzip", None, true), + ("preview+gzip, PDF disabled", Some(false), true), + ] { + let mut session = vectorcraft_engine::Session::new(); + session.add_document(doc.clone(), None); + let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview}); + if let Some(value) = compatible { + params["pdfCompatible"] = json!(value); + } + let (mut saves, mut opens) = (Vec::new(), Vec::new()); + let (mut size, mut has_pdf, mut compressed) = (0, false, false); + for run in 0..6 { + let start = Instant::now(); + let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?; + let save_ms = start.elapsed().as_secs_f64() * 1000.0; + let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?; + let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?; + size = bytes.len(); + has_pdf = vectorcraft_format::pdf_content(&bytes).is_some(); + if compatible == Some(false) && has_pdf { + return Err("native save ignored the explicit PDF opt-out".into()); + } + compressed = vectorcraft_format::is_compressed(&bytes); + if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview { + return Err("native save did not apply the requested preview/compression options".into()); + } + let args = json!({"name":"perf.vectorcraft","dataBase64":data}); + let mut opened = vectorcraft_engine::Session::new(); + let start = Instant::now(); + opened.execute("document.open", &args).map_err(|e| e.to_string())?; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() { + return Err("native round trip changed the artboard count".into()); + } + if run > 0 { + saves.push(save_ms); + opens.push(open_ms); + } + } + saves.sort_by(f64::total_cmp); + opens.sort_by(f64::total_cmp); + let save = saves.get(2).copied().ok_or("no save measurements")?; + let open = opens.get(2).copied().ok_or("no open measurements")?; + outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}"); + } + Ok(()) +} + /// 1-minute load average (macOS / Linux), if available. #[cfg(not(windows))] fn load_average() -> Option { @@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { - if crate::help_requested(args, &["--paths"]) { + if crate::help_requested(args, &["--paths", "--artboards"]) { return crate::help(); } let mut n = 50_000usize; let mut want_deep = false; + let mut want_native = false; + let mut artboards = None::; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--deep" => want_deep = true, + "--native-io" => want_native = true, + "--artboards" => { + artboards = + Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?) + } "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, other => return Err(format!("unknown perf option `{other}`")), } } + if want_native && n > 50_000 { + return Err("--native-io allows at most 50000 paths".into()); + } + if want_native && want_deep { + return Err("--native-io and --deep are separate measurement modes".into()); + } + if artboards.is_some() && !want_native { + return Err("--artboards requires --native-io".into()); + } + let artboards = artboards.unwrap_or(1); let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); // The figure is the 1-minute load average, or on Windows the cores busy over one second. #[cfg(not(windows))] @@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> { outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); } let t = Instant::now(); - let doc = synthetic(n)?; + let mut doc = synthetic(n)?; outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + if want_native { + let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?; + // Overlapping artboards intentionally repeat the same paths on every PDF page. + for index in 1..artboards { + let mut page = first.clone(); + page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?; + page.name = format!("Perf {}", index + 1); + doc.artboards.push(page); + } + outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O"); + return native_io(&doc); + } let mut rows: Vec<(&str, f64, f64)> = vec![]; let opts = RenderOptions::default(); @@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> { mod tests { use super::*; + #[test] + fn native_io_exercises_the_application_save_and_open_commands() { + native_io(&synthetic(3).unwrap()).unwrap(); + } + + #[test] + fn native_io_rejects_conflicting_modes_and_excessive_workloads() { + for (args, expected) in [ + (vec!["--native-io", "--deep"], "separate measurement modes"), + (vec!["--native-io", "--paths", "50001"], "at most 50000"), + (vec!["--native-io", "--artboards", "5"], "1 to 4"), + (vec!["--artboards", "1"], "requires --native-io"), + (vec!["--artboards", "2"], "requires --native-io"), + ] { + assert!(run(&args.into_iter().map(str::to_owned).collect::>()).unwrap_err().contains(expected)); + } + } + #[test] fn busy_share_leaves_out_idle_time() { // In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time, diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 03bfde85b..7ff22689c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -29,6 +29,8 @@ pub struct Loaded { /// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art /// that it carries (its layers), not from its PDF part alone. pub editing_data: bool, + /// Whether the native source declared a PDF; other formats carry no native save preference. + pub native_pdf_compatible: Option, } impl Loaded { @@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let mut converted = false; let mut preview_only = false; let mut editing_data = false; + let mut native_pdf_compatible = None; let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { @@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "vectorcraft" | "template" => { let (d, info, warnings) = native_file(bytes)?; + native_pdf_compatible = Some(info.pdf_compatible); converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION; (d, warnings, false) } @@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result doc.title = title; } warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes))); - Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data }) + Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible }) } /// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's @@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `links` is what became of its linked files, and /// `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { - let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs); + let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing // fallbacks must ask before overwriting the richer source they could not fully read. let losses: Vec = warnings @@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, include_linked: bool, embed_profiles: Option, - pdf_compatible: bool, + pdf_compatible: Option, } /// What [`save_options`] leaves to [`encode`] (rendering and encoding take time). @@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> 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 })) + let pdf = o.pdf_compatible.unwrap_or(true); + so.pdf_compatible = Some(pdf); + Ok((so, Extras { preview: o.preview, pdf })) } /// The `precision` option `v`: a whole number of decimal places, 0 to @@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp if extras.preview { so.preview = preview_png(&drawn)?; } - if extras.pdf { - // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0); + if extras.pdf && !drawn.artboards.is_empty() { + // Every artboard as PDF pages (no editing data: the file is the native document), dated by + // the document's File Info, so the same document and dates give the same bytes. + let at = drawn.metadata.modified.or(drawn.metadata.created); + so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0); } drop(drawn); let native = match full { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 785764db9..52e7489dd 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { + options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time()) +} + +/// [`options`] modified at `now` (Unix seconds) instead of the time of writing. +fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; - let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, @@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file /// 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_at(cmd, doc, p, None) +} + +/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -294,7 +303,20 @@ pub(super) fn encode_carrying( native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, ) -> Result<(Vec, Vec)> { - let mut opts = options(cmd, doc, p)?; + encode_carrying_at(cmd, doc, p, None, native, private) +} + +/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of +/// writing. +fn encode_carrying_at( + cmd: &str, + doc: &Document, + p: &Value, + modified: Option, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { + let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0eefa45c0..893b783bf 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; + // Remember the effective choice, including the encoder's default, so another document's + // later save cannot change what this one writes. Invalid non-null values still reach validation. + if matches!(format.id, "vectorcraft" | "template") { + let pdf = match options.get("pdfCompatible") { + None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)), + _ => None, + }; + if let Some(pdf) = pdf { + options.insert("pdfCompatible".into(), json!(pdf)); + } + } // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool { } } +/// Native documents and templates share only their PDF choice. Other source formats use the +/// application's last native choice, keeping an AI file's separate PDF option out of it. +fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option { + st.filter(|d| matches!(d.format, "vectorcraft" | "template")) + .and_then(|d| d.save_options.get("pdfCompatible")) + .and_then(Value::as_bool) + .or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool)) +} + /// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) /// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is /// left out, and one that moved is named by its new number. When none of the artboards the save @@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if let Some(pdf) = native_pdf_compatible { + s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); + } Ok(json!({ "path": path })) } @@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result { .or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone())); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); + let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten(); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); + if name == "pdfCompatible" + && let Some(pdf) = native_pdf + { + o["value"] = json!(pdf); + } } } v["saveFormats"] = @@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), 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; a .ai file's compress, default true, is its 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, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS 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|eps (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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), 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?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its 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, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS 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/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index fca92d73f..84513cd2c 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] { let path = d.join(name).to_string_lossy().to_string(); - let p = json!({"path": path, "compress": compress}); + // A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison + // uses files without one. + let p = json!({"path": path, "compress": compress, "pdfCompatible": false}); let r = s.execute("file.saveCopy", &p).unwrap(); let written = std::fs::read(&path).unwrap(); assert_eq!(r["bytes"], written.len(), "{r}"); @@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { assert_eq!(written, job.encode().unwrap().files[0].1, "{name}"); assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate")); } + // A save with the default options streams a file that carries its PDF. + let path = d.join("d.vectorcraft").to_string_lossy().to_string(); + let p = json!({"path": path}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let written = std::fs::read(&path).unwrap(); + assert_eq!(r["bytes"], written.len(), "{r}"); + assert!(vectorcraft_format::pdf_content(&written).is_some()); + let plan = save_plan(&s, SaveMode::Copy, &p).unwrap(); + let job = save_job(&mut s, plan).unwrap(); + assert!(job.streams()); + assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some()); // A writer that fails: the save fails with its error. let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap(); let job = save_job(&mut s, plan).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 73ee604a9..eb3989597 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() { let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); s.execute("document.save", &json!({"path": native})).unwrap(); assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap())); - assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options"); + assert_eq!( + Value::Object(s.doc().unwrap().save_options.clone()), + json!({"pdfCompatible":true}), + "a native save retains its PDF choice without SVG options" + ); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 1ad456b94..8b5731b33 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -565,6 +565,10 @@ struct Meta { path: Option, /// The format Save writes for it ([`SAVE_FORMATS`]). format: String, + /// A document's own native PDF choice before the recovery copy omits its PDF. None keeps + /// inheritance from the current application preference. Older copies did not record a choice. + #[serde(skip_serializing_if = "Option::is_none")] + native_pdf_compatible: Option, /// When the copy was written (Unix seconds; none without a clock). saved: Option, /// The file it was read from when that import left things out of the document, and what @@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec) -> Result<(Vec, file: &str) -> R st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); + if let Some(pdf) = meta.native_pdf_compatible { + st.save_options.insert("pdfCompatible".into(), json!(pdf)); + } st.mark_unsaved(); // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); diff --git a/crates/engine/src/tests/nativefile.rs b/crates/engine/src/tests/nativefile.rs index e3a5588b7..4156dc759 100644 --- a/crates/engine/src/tests/nativefile.rs +++ b/crates/engine/src/tests/nativefile.rs @@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() { /// A save records the date the document was made in, so the same commands twice gave different /// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and -/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by +/// default is dated by the document's dates, not by the time of writing. #[test] fn a_pinned_date_makes_saves_byte_identical() { let once = |t: i64| { @@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() { saved(&mut s, json!({})) }; let at = 1_791_208_980; + let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default"); + let info = vectorcraft_pdf::file_info(&pdf, None).unwrap(); + assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates"); assert_eq!(once(at), once(at), "the same document and date give the same bytes"); // The date really is in the bytes, so the comparison above is not vacuous. assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 56c8d899d..9fe2d846c 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize { r["restored"].as_array().unwrap().len() } +#[test] +fn native_recovery_copies_leave_out_the_pdf() { + let (mut s, store) = session(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap()); + let bytes = store.read(&file).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count()); +} + +#[test] +fn restored_recovery_copies_keep_the_original_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-pdf-preference"); + for pdf in [false, true] { + let (mut s, store) = session(); + s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + let copied = s.execute("file.recovery.save", &json!({})).unwrap(); + let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap()); + assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none()); + drop(s); + let mut s = relaunch(&store); + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf))); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf); + } +} + +#[test] +fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() { + let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice"); + let (mut s, store) = session(); + s.execute("file.recovery.save", &json!({})).unwrap(); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap(); + assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty()); + let latest_preferences = s.prefs.clone(); + drop(s); + let mut s = relaunch(&store); + // The host loads its latest preferences before offering recovery. + s.prefs = latest_preferences; + assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1); + assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none()); + let saved = s.execute("document.save", &json!({})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); +} + #[test] fn recovery_save_writes_modified_documents_once() { let (mut s, store) = session(); diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 9324b1f4b..a5b19563a 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() { 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()); + assert!( + vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none() + ); +} + +/// A native save is portable to the other apps by default. The option is still an explicit +/// opt-out, and a remembered opt-out survives later saves of the document. +#[test] +fn native_saves_carry_all_artboards_as_pdf_by_default() { + let mut s = three_boards(); + for compress in [false, true] { + let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF"); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap(); + assert_eq!(info["pages"], 3); + assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3); + } + let dir = Folder::new("pdf-opt-out"); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({"compress": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none()); + s.execute("document.save", &json!({"pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); +} + +#[test] +fn native_saves_without_artboards_keep_the_editable_document() { + let mut s = three_boards(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + let file = bytes_of(&result["dataBase64"]); + let restored = vectorcraft_format::load(&file).unwrap(); + assert!(restored.artboards.is_empty()); + assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count()); + assert!(vectorcraft_format::pdf_content(&file).is_none()); + s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap(); + let result = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some()); +} + +#[test] +fn native_template_saves_keep_the_document_pdf_preference() { + let mut s = three_boards(); + let dir = Folder::new("template-pdf-preference"); + s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice"); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap(); + let bytes = bytes_of(&r["dataBase64"]); + assert!(vectorcraft_format::pdf_content(&bytes).is_none()); + assert_eq!( + vectorcraft_format::load_file(&bytes).unwrap().info.version, + vectorcraft_format::VERSION, + "unrelated format options are not carried over" + ); + let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some()); +} + +#[test] +fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() { + let dir = Folder::new("default-native-pdf-choice"); + for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() { + let mut s = three_boards(); + let path = dir.file(&format!("first-{index}.vectorcraft")); + let mut params = options.as_object().unwrap().clone(); + params.insert("path".into(), json!(path)); + s.execute("file.saveAs", &Value::Object(params)).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some()); + s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap(); + s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap(); + s.execute("document.activate", &json!({"index":0})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true))); + } +} + +#[test] +fn a_null_nested_pdf_option_keeps_the_saved_opt_out() { + let dir = Folder::new("null-nested-pdf-option"); + let mut s = three_boards(); + let path = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap(); + s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap(); + assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice"); + assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false))); +} + +#[test] +fn reopening_native_files_keeps_each_files_pdf_compatibility() { + let dir = Folder::new("pdf-reopened-options"); + let mut s = three_boards(); + let pdf = dir.file("portable.vectorcraft"); + let compact = dir.file("compact.vectorcraft"); + s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap(); + s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let copy = s.execute("file.saveCopy", &json!({})).unwrap(); + assert!(vectorcraft_format::pdf_content(&bytes_of(©["dataBase64"])).is_none(), "new files use the last native preference"); + for (path, expected) in [(&pdf, true), (&compact, false)] { + s.execute("document.open", &json!({"path": path})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], expected); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected); + } +} + +#[test] +fn reverting_native_files_updates_their_pdf_compatibility() { + let dir = Folder::new("pdf-revert-options"); + for saved in [false, true] { + let mut s = three_boards(); + let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" }); + s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap(); + let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap(); + std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap(); + s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + assert_eq!(options["options"]["pdfCompatible"]["value"], !saved); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved); + } } #[test] @@ -429,7 +568,7 @@ fn format_options_list_the_save_options() { 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)); + assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true)); // Saved with them, a document remembers them for Save. let dir = Folder::new("remember"); let path = dir.file("r.vectorcraft"); diff --git a/crates/engine/tests/plugin_export.rs b/crates/engine/tests/plugin_export.rs index 1a49f800a..df06b5ee2 100644 --- a/crates/engine/tests/plugin_export.rs +++ b/crates/engine/tests/plugin_export.rs @@ -69,9 +69,8 @@ fn an_exporter_adds_a_write_only_format_while_it_is_installed() { assert!(e.to_string().contains("unknown format"), "{e}"); } -/// The exporter is given the document as a native file's JSON: what `document.save` writes before -/// the data of large images, which stays behind (compact, where a save of a small document is -/// indented). +/// The exporter receives the native document JSON without optional save renditions or preference +/// metadata. Large image bytes stay in the saved file's tail; plug-in input stays compact. #[test] fn the_exporter_is_given_the_native_file_s_json() { let mut s = session(); @@ -94,8 +93,14 @@ fn the_exporter_is_given_the_native_file_s_json() { let saved = exported(&s.execute("document.save", &json!({})).unwrap()); assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file"); let doc: Value = serde_json::from_slice(&given).unwrap(); - let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); - assert_eq!(doc, saved_json, "the saved file's JSON"); + let mut saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap(); + assert!(saved_json.get("pdf").is_some(), "a default application save carries a PDF"); + assert_eq!(saved_json["pdfCompatible"], true); + assert!(doc.get("pdf").is_none() && doc.get("pdfCompatible").is_none(), "the plug-in input omits optional save members"); + let saved_members = saved_json.as_object_mut().unwrap(); + saved_members.remove("pdf"); + saved_members.remove("pdfCompatible"); + assert_eq!(doc, saved_json, "the native document JSON without optional save members"); assert!(!given.contains(&b'\n'), "without indentation"); assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION))); let images = doc["images"].as_object().unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f82cd4eb1..b3aa7eab7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -45,11 +45,13 @@ mod stream; mod unpack; use std::collections::{BTreeMap, HashSet}; +use std::fmt; use std::io::Write; use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; -use serde::Serialize; +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -146,6 +148,9 @@ pub struct SaveOptions { pub profiles: BTreeMap>, /// Create PDF-Compatible File: a PDF of the document to carry. pub pdf: Option>, + /// The application's PDF save preference, retained even when there are no artboards to draw. + /// `None` omits this metadata, preserving the lower-level writer's existing output. + pub pdf_compatible: Option, /// Decimal places to round path anchors and handles to ([`Document::round_geometry`]), at /// most [`MAX_PRECISION`] (a larger value rounds to that many); `None` writes them exactly. pub precision: Option, @@ -164,6 +169,7 @@ impl Default for SaveOptions { include_linked: false, profiles: BTreeMap::new(), pdf: None, + pdf_compatible: None, precision: None, } } @@ -327,6 +333,9 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; env.field(&mut out, "version", &o.version)?; env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; + if let Some(compatible) = o.pdf_compatible { + env.field(&mut out, "pdfCompatible", &compatible)?; + } if let Some(png) = &o.preview { env.field(&mut out, "preview", &Image::inline("image/png", png))?; } @@ -470,6 +479,9 @@ pub struct FileInfo { pub version: u32, /// Written under the project's former name (`"format": "drawcraft"`). pub legacy: bool, + /// The saved `pdfCompatible` preference, or the presence of a non-null `pdf` member in + /// files without a boolean preference. The PDF payload is not read or validated. + pub pdf_compatible: bool, } impl FileInfo { @@ -511,7 +523,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ReadError::Envelope(e) => e.into(), e => FormatError::NotVectorcraft(e.to_string()), })?; - let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }; + let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible }; // A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file // version): its Twist angles turned the other way. The app that wrote it is read from the JSON // before the data after it is taken; the walk's depth is bounded by the JSON reader's nesting @@ -602,6 +615,92 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result Ok(NativeFile { doc, depth_notes, info, profiles }) } +/// The saved PDF preference, falling back to the presence of its payload without retaining or +/// decoding it. The array form has neither member. Repeated members use their last value. +fn pdf_member(json: &[u8]) -> Result { + struct PdfMember; + impl<'de> Visitor<'de> for PdfMember { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut present = false; + let mut preference = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "pdfCompatible" => preference = map.next_value_seed(Preference)?, + "pdf" => present = map.next_value::>()?.is_some(), + _ => { + map.next_value::()?; + } + } + } + Ok(preference.unwrap_or(present)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(false) + } + } + serde_json::Deserializer::from_slice(json).deserialize_any(PdfMember) +} + +/// A `pdfCompatible` value: `Some` for a boolean, `None` for null or any other value, which is +/// skipped without being kept, as an unknown member is. +struct Preference; + +impl<'de> DeserializeSeed<'de> for Preference { + type Value = Option; + + fn deserialize>(self, d: D) -> Result, D::Error> { + d.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Preference { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("any value") + } + + fn visit_bool(self, v: bool) -> Result, E> { + Ok(Some(v)) + } + + fn visit_i64(self, _: i64) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, _: u64) -> Result, E> { + Ok(None) + } + + fn visit_f64(self, _: f64) -> Result, E> { + Ok(None) + } + + fn visit_str(self, _: &str) -> Result, E> { + Ok(None) + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_seq>(self, seq: A) -> Result, A::Error> { + IgnoredAny.visit_seq(seq).map(|_| None) + } + + fn visit_map>(self, map: A) -> Result, A::Error> { + IgnoredAny.visit_map(map).map(|_| None) + } +} + /// The file's first `generator` member, the app that wrote it (`None`: it has none, it is not a /// string, or the file is not an object of members). The other members, and any further /// `generator`, are skipped without being kept, as reading the file skips them. @@ -671,17 +770,59 @@ mod tests { assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err()); } + #[test] + fn file_info_keeps_pdf_presence_without_reading_its_payload() { + let d = Document::new(10.0, 10.0); + for version in [3, 4] { + for compress in [false, true] { + for present in [false, true] { + let options = SaveOptions { version, compress, pdf: present.then(|| vec![7; 128 * 1024]), ..SaveOptions::default() }; + let bytes = save_with(&d, &options).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, present); + } + } + } + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let array = serde_json::json!([EXTENSION, VERSION, value["document"], value["images"], {}]); + assert!(!load_info(&serde_json::to_vec(&array).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::json!({"mime":"application/pdf", "data":"not base64"}); + assert!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + value["pdf"] = serde_json::Value::Null; + value["unrecognized"] = serde_json::json!({"pdf":{"data":"nested"}}); + assert!(!load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible); + // A preference that is not a boolean is skipped like an unknown member: the file opens, + // and the presence of the PDF decides. + for odd in [serde_json::json!(1), serde_json::json!("yes"), serde_json::json!([true]), serde_json::json!({"on": true})] { + for (pdf, present) in [(serde_json::Value::Null, false), (serde_json::json!({"mime":"application/pdf"}), true)] { + value["pdf"] = pdf; + value["pdfCompatible"] = odd.clone(); + assert_eq!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible, present, "{odd}"); + } + } + } + + #[test] + fn file_info_keeps_the_pdf_preference_without_artboards() { + let mut d = Document::new(10.0, 10.0); + d.artboards.clear(); + for compatible in [false, true] { + let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap(); + assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, compatible); + assert!(pdf_content(&bytes).is_none()); + } + } + #[test] fn file_info_tells_old_files() { let d = Document::new(10.0, 10.0); let current = save(&d, false); - assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false }); + assert_eq!(load_info(¤t).unwrap().1, FileInfo { version: VERSION, legacy: false, pdf_compatible: false }); assert!(!load_info(¤t).unwrap().1.is_old()); let text = String::from_utf8(current).unwrap(); let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1); assert!(load_info(legacy.as_bytes()).unwrap().1.is_old()); let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1); - assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false }); + assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false, pdf_compatible: false }); assert!(load_info(v1.as_bytes()).unwrap().1.is_old()); } diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 1f33575e7..959079968 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -221,6 +221,7 @@ fn save_a_copy_and_template_suggest_their_names() { assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); assert!(app.ui.dialog.is_none(), "the native format has no options"); assert!(vectorcraft_format::sniff(&written.borrow()[0].1)); + assert!(vectorcraft_format::pdf_content(&written.borrow()[0].1).is_some()); assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty()); app.run("file.saveAsTemplate", json!({})).unwrap(); let pick = &picks.borrow()[1]; diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 59b8c91de..5f136d514 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -32,7 +32,7 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { 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) + ("vectorcraft", false, true, true) ); assert!(written.borrow().is_empty(), "nothing is written before OK"); frame(&mut app); diff --git a/docs/mcp.md b/docs/mcp.md index 366f31921..0c42c160f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4976,8 +4976,12 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `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 +- `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves + larger and adds PDF rendering work. Set it to false to leave the PDF out; Save remembers that choice. `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). + Native files without artboards and Data Recovery copies omit the PDF. + Reopening a native file keeps its saved PDF preference, falling back to whether it declared a PDF in older files. + Existing files saved without a PDF remain compact. - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. - The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..66866efcd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -282,8 +282,8 @@ object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry. -An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it -but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, +An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF +renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`.