From 0ffaa427d1561296f3a29e4466f470ba5a9deaec Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 09:49:41 +0700 Subject: [PATCH] M4.43: PDFs and .ai files reopen editable; Save As writes PDF-compatible .ai Preserve Editing (on in the VectorCraft Default preset) embeds the native document in the PDF as an embedded file with a hash of the pages it was written with. document.open of a PDF, .ai or .ait restores it exactly (restored: true) and falls back to importing the artwork, with a first warning saying why, when the pages were changed elsewhere or the data can't be read; SVG and PDF share that fallback and its warnings. The legacy drawcraft payload name is read too. vectorcraft_pdf gains editing(), ImportReport.native and PdfOptions.native. document.save / file.saveAs / Save a Copy write .ai (format "ai" or an .ai path): every artboard as a PDF that always carries the document and takes the PDF options; a restored .ai keeps its path so Save writes .ai again, and .ait templates open their document untitled. The desktop save dialog lists the formats Save writes. Choosing a standard turns Preserve Editing off (PDF/A refuses it), Export for Screens and PDFs of some artboards leave it out with a warning. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/main.rs | 9 +- crates/engine/src/cmd/fileio/encode.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 8 + crates/engine/src/cmd/fileio/load.rs | 83 +++++---- crates/engine/src/cmd/fileio/mod.rs | 8 +- crates/engine/src/cmd/fileio/pdf.rs | 137 ++++++++++---- crates/engine/src/cmd/fileio/save.rs | 52 ++++-- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 170 ++++++++++++++++++ crates/engine/src/cmd/fileio/tests_svgedit.rs | 4 +- crates/engine/src/lib.rs | 5 + crates/engine/tests/import_fuzz.rs | 30 ++++ crates/mcp/src/headless.rs | 8 +- crates/pdf/src/editing.rs | 123 +++++++++++++ crates/pdf/src/export.rs | 15 +- crates/pdf/src/import.rs | 2 +- crates/pdf/src/lib.rs | 15 +- crates/pdf/src/presets.rs | 37 ++++ crates/pdf/src/settings.rs | 20 ++- crates/pdf/src/tests_editing.rs | 90 ++++++++++ crates/pdf/src/tests_settings.rs | 1 - crates/ui-egui/src/dialogs/save_pdf.rs | 20 ++- crates/ui-egui/src/io.rs | 25 ++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 16 +- crates/ui-egui/src/tests_aisave.rs | 37 ++++ docs/mcp.md | 10 +- 26 files changed, 811 insertions(+), 118 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pdfedit.rs create mode 100644 crates/pdf/src/editing.rs create mode 100644 crates/pdf/src/presets.rs create mode 100644 crates/pdf/src/tests_editing.rs create mode 100644 crates/ui-egui/src/tests_aisave.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 1aa4dc7d..79d7c269 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -113,7 +113,14 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), + // Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz). + pick_save: Some(Box::new(|name: &str| { + fileio::save_filters(name) + .into_iter() + .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts)) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), // Menu-bar Paste never sees egui's Paste event, so read the clipboard directly. diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0440776a..c8a1e8b3 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -70,7 +70,7 @@ pub struct Encoded { } impl Encoded { - fn one(bytes: Vec) -> Self { + pub(super) fn one(bytes: Vec) -> Self { Self { files: vec![(None, bytes)], ..Self::default() } } diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 7759d1c4..dae09701 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -16,6 +16,8 @@ use super::{ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; + let expanded = super::pdf::expand_preset(s, "document.serialize", p)?; + let p = &*expanded; let doc = &s.doc()?.doc; let enc = encode_all(doc, f.id, p)?; let files = enc.named(doc, &default_name(doc, f.extensions[0])); @@ -38,6 +40,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { pub(super) fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path"); let f = writable("document.export", str_param(p, "format"), path)?; + let expanded = super::pdf::expand_preset(s, "document.export", p)?; + let p = &*expanded; let doc = &s.doc()?.doc; let enc = encode_all(doc, f.id, p)?; write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) @@ -63,6 +67,8 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportSelection"; let path = str_param(p, "path"); let f = writable(C, str_param(p, "format"), path)?; + let expanded = super::pdf::expand_preset(s, C, p)?; + let p = &*expanded; let ids = edit::selected_roots(s)?; let st = s.doc()?; let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); @@ -114,6 +120,8 @@ fn screen_format(row: &Value) -> Result { let mut options = without_artboards(row); if let Some(o) = options.as_object_mut() { o.insert("scale".into(), json!(scale)); + // A file per artboard is for viewing: it doesn't carry the whole document to edit. + o.entry("preserveEditing").or_insert(json!(false)); } Ok(ScreenFormat { format, options, suffix }) } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fb0e1722..612aacf3 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -16,6 +16,8 @@ pub struct Loaded { pub doc: Document, pub format: &'static Format, pub warnings: Vec, + /// The document is the native one the file carries (SVG or PDF saved with Preserve Editing). + pub restored: bool, } /// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG @@ -67,10 +69,34 @@ fn image_format(f: image::ImageFormat) -> Option<&'static Format> { f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster) } -/// Why an SVG carrying editing data opened as plain SVG: it was changed after it was saved. -pub const EDITING_STALE: &str = "this SVG was changed in another app after it was saved with its editing data: it opened as plain SVG"; -/// Why an SVG carrying editing data opened as plain SVG: the data can't be read. -pub const EDITING_DAMAGED: &str = "this SVG's editing data can't be read: it opened as plain SVG"; +/// Why a file carrying editing data (an SVG or PDF saved with Preserve Editing) opened as plain +/// artwork: it was changed in another app after it was saved. +pub const EDITING_STALE: &str = "this file was changed in another app after it was saved with its editing data: it opened as plain artwork"; +/// Why a file carrying editing data opened as plain artwork: the data can't be read. +pub const EDITING_DAMAGED: &str = "this file's editing data can't be read: it opened as plain artwork"; + +/// Preserve Editing, for every format that carries the native document: that document when the +/// file around it is unchanged (`editing`: whether it is, and how to get the document's bytes) and +/// it reads, else `import()`'s document with a first warning saying why the editing data wasn't +/// used → (document, warnings, restored). +fn restore_or_import( + editing: Option<(bool, impl FnOnce() -> Option>)>, + import: impl FnOnce() -> Result<(Document, Vec)>, +) -> Result<(Document, Vec, bool)> { + 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)), + None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }), + } + } + let (doc, mut warnings) = import()?; + if let Some(why) = fallback { + warnings.insert(0, why.to_string()); + } + Ok((doc, warnings, false)) +} /// Read a file of any readable format (`name`: its file name or path, for the extension and title). pub fn load(name: &str, bytes: &[u8]) -> Result { @@ -86,37 +112,19 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?; let title = file_name(name); - let mut warnings = vec![]; - let mut doc = match format.id { - "vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?, + let (mut doc, warnings, restored) = match format.id { + "vectorcraft" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false), "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; - // An SVG saved with Preserve Editing carries the native document: open that, unless - // the SVG was edited since (or the data is damaged). - let editing = vectorcraft_svg::editing(&text); - let native = editing - .as_ref() - .filter(|e| e.intact) - .and_then(|e| vectorcraft_format::base64_decode(&e.data)) - .and_then(|b| vectorcraft_format::load(&b).ok()); - match native { - Some(d) => d, - None => { - let (d, w) = vectorcraft_svg::import_with_report(&text).map_err(err)?; - warnings = w; - if let Some(e) = editing { - warnings.insert(0, (if e.intact { EDITING_DAMAGED } else { EDITING_STALE }).to_string()); - } - d - } - } + let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); + restore_or_import(editing, || vectorcraft_svg::import_with_report(&text).map_err(err))? } "pdf" | "ai" | "ait" => { - let (d, w) = super::pdfimport::import(bytes, opts)?; - warnings = w; - d + // Saved with Preserve Editing: the document it carries, unless some pages are picked. + let editing = opts.pages.is_none().then(|| vectorcraft_pdf::editing(bytes)).flatten().map(|e| (e.intact, move || Some(e.data))); + restore_or_import(editing, || super::pdfimport::import(bytes, opts))? } - _ if format.raster => raster_doc(&title, bytes)?, + _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { @@ -127,13 +135,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result if format.id != "vectorcraft" || doc.title.is_empty() { doc.title = title; } - Ok(Loaded { doc, format, warnings }) + Ok(Loaded { doc, format, warnings, restored }) } /// Open a file's bytes as the new active document (what `document.open` does) → -/// `{index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks}`. `path` is kept -/// for Save only for a native, non-template file; the document's linked files are looked for from -/// it ([`crate::cmd::links::resolve`]). +/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. `path` +/// is kept for Save only for a non-template native file or a `.ai` file whose native document came +/// back (Save writes both again); the document's linked files are looked for from it +/// ([`crate::cmd::links::resolve`]). pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option) -> Result { open_bytes_with(s, name, bytes, path, &Value::Null) } @@ -141,7 +150,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?; + let Loaded { mut doc, format, warnings, restored } = load_with(name, bytes, &opts)?; let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically"); // A template (saved by Save as Template, or an .ait file) opens as a new untitled document. let template = doc.template || format.id == "ait"; @@ -149,10 +158,10 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)", always, load::open ), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A never-saved document without path → {dataBase64} (stays modified)", + "{path?, format?: vectorcraft|ai|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}, …document.exportPdf options (.ai)} (default path: the document's) → {path, linked?, warnings?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A .ai save is a PDF-compatible file: every artboard as a PDF page with the native document embedded (preserveEditing is always on), so document.open restores it exactly. A never-saved document without path → {dataBase64} (stays modified)", has_doc, save::save ), @@ -513,6 +513,8 @@ mod tests_pdf; #[cfg(test)] mod tests_pdfcolor; #[cfg(test)] +mod tests_pdfedit; +#[cfg(test)] mod tests_pdfimport; #[cfg(test)] mod tests_svg; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a299a872..fb23b331 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -1,11 +1,13 @@ //! PDF export settings for every PDF path (`document.export {format: pdf}`, Export for Screens, -//! `document.exportPdf` and the Save PDF dialog): presets, the options parsed over them, the -//! warnings that come back, and `document.pdfSettings` (the dialog's Summary). +//! `document.exportPdf` and the Save PDF dialog): presets (built-in and saved), the options parsed +//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary). + +use std::borrow::Cow; use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_doc::Document; -use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfSettings}; +use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard}; use super::super::*; use super::{ArtboardPick, FormatOption, write_or_return}; @@ -14,7 +16,7 @@ use crate::EngineError; const C: &str = "document.exportPdf"; /// The built-in preset every PDF export starts from. -pub const DEFAULT_PRESET: &str = "VectorCraft Default"; +pub use vectorcraft_pdf::DEFAULT_PRESET; /// The PDF options `document.formats` lists; `document.exportPdf` documents every field. pub(super) const OPTIONS: &[FormatOption] = &[ @@ -25,6 +27,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" }, FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" }, FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" }, + FormatOption { + name: "preserveEditing", + ty: "boolean", + default: "true", + description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)", + }, FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" }, FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" }, FormatOption { name: "bleed", ty: "object", default: "null", description: "{useDocument, top, bottom, left, right} in points" }, @@ -40,7 +48,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\", artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing?, thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\", artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), @@ -56,16 +64,27 @@ pub fn specs() -> Vec { ] } -/// Every preset name (built-in first). -pub fn presets() -> Vec { - vec![DEFAULT_PRESET.to_string()] +/// Every preset name: the built-in ones, then the saved ones ([`crate::Prefs::pdf_presets`]). +pub fn presets(s: &Session) -> Vec { + vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(s.prefs.pdf_presets.iter().map(|p| p.name.clone())).collect() } -fn preset(cmd: &str, name: &str) -> Result { - if name.eq_ignore_ascii_case(DEFAULT_PRESET) || name.eq_ignore_ascii_case("default") { - return Ok(PdfSettings::default()); - } - Err(bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", presets().join(", ")))) +/// The preset `name` names: a built-in one (any case; `default` is the app default) or one of +/// `saved`. +pub fn find_preset(name: &str, saved: &[PdfPreset]) -> Option { + vectorcraft_pdf::builtin_preset(name).or_else(|| saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())).cloned()) +} + +fn preset(cmd: &str, name: &str, saved: &[PdfPreset]) -> Result { + find_preset(name, saved).map(|p| p.settings).ok_or_else(|| { + let names: Vec = vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect(); + bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names.join(", "))) + }) +} + +/// The settings of the preset `p` names (default: [`DEFAULT_PRESET`]), before `p`'s options. +pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result { + preset(cmd, str_param(p, "preset").unwrap_or(DEFAULT_PRESET), saved) } /// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the @@ -89,23 +108,55 @@ fn pdf_error(cmd: &str, e: PdfError) -> EngineError { } } -/// The settings `p` asks for: its preset (default: [`DEFAULT_PRESET`]) with `p`'s options applied -/// over it, checked. Keys that aren't PDF options (path, format…) are ignored. -pub fn settings(cmd: &str, p: &Value) -> Result { - let base = match str_param(p, "preset") { - Some(name) => preset(cmd, name)?, - None => PdfSettings::default(), - }; +/// The settings `p` asks for: its preset (built-in or one of `saved`; default +/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns +/// Preserve Editing off unless `p` asks for it. Checked as a preset is +/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that +/// aren't PDF options (path, format…) are ignored. +pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result { + let base = preset_settings(cmd, p, saved)?; if !p.is_object() { return Ok(base); } let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; merge(&mut v, p); - let s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?; + let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?; + if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) { + s.preserve_editing = false; + } + s.check_values().map_err(|e| pdf_error(cmd, e))?; + Ok(s) +} + +/// [`resolve`], refusing what the writer can't honour ([`PdfSettings::check`]). +pub fn settings_with(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result { + let s = resolve(cmd, p, saved)?; s.check().map_err(|e| pdf_error(cmd, e))?; Ok(s) } +/// [`settings_with`] the built-in presets only (what the encoders know: see [`expand_preset`]). +pub fn settings(cmd: &str, p: &Value) -> Result { + settings_with(cmd, p, &[]) +} + +/// `p` with a saved preset it names written out as options, for the encoders, which know only the +/// built-in presets. Params naming no preset, or a built-in one, come back as they are. +pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result> { + match str_param(p, "preset") { + Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => { + let set = settings_with(cmd, p, &s.prefs.pdf_presets)?; + let mut q = p.clone(); + merge(&mut q, &serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))?); + if let Some(o) = q.as_object_mut() { + o.remove("preset"); + } + Ok(Cow::Owned(q)) + } + _ => Ok(Cow::Borrowed(p)), + } +} + /// The full export options for `doc`: settings plus the artboards `p` picks (default all). pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; @@ -113,12 +164,26 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() }) } +/// Why a PDF of some artboards carries no editing data. +pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = + "Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)"; + /// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered -/// to images at the document's raster effects resolution. +/// 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)> { - let opts = options(cmd, doc, p)?; + 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)); + } else { + opts.settings.preserve_editing = false; + warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); + } + } let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?; - let mut warnings = r.warnings; + warnings.extend(r.warnings); if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) { warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into()); } @@ -126,6 +191,8 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec Result { + let expanded = expand_preset(s, C, p)?; + let p = &*expanded; let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) } @@ -144,16 +211,24 @@ fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec) { } } +/// `[{option, value}]` for every setting of `set` that differs from `base` +/// (`compression.color.ppi`). +pub fn changes(set: &PdfSettings, base: &PdfSettings) -> Vec { + let (Ok(v), Ok(base)) = (serde_json::to_value(set), serde_json::to_value(base)) else { return vec![] }; + let mut diff = vec![]; + changed("", &v, &base, &mut diff); + diff +} + fn pdf_settings(s: &mut Session, p: &Value) -> Result { const Q: &str = "document.pdfSettings"; - let set = settings(Q, p)?; - let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; - let default = serde_json::to_value(PdfSettings::default()).map_err(|e| EngineError::Other(e.to_string()))?; - let mut diff = vec![]; - changed("", &v, &default, &mut diff); + let saved = &s.prefs.pdf_presets; + let set = settings_with(Q, p, saved)?; + let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, p)?.1, + Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, _ => set.warnings(), }; - Ok(json!({ "settings": v, "presets": presets(), "changed": diff, "warnings": warnings })) + let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; + Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings })) } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 01420a2f..6b6d3c70 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1,4 +1,4 @@ -//! Saves: `document.save` (native or SVG) and `file.saveAsTemplate`. +//! Saves: `document.save` (native, SVG or PDF-compatible .ai) and `file.saveAsTemplate`. use serde_json::{Value, json}; @@ -6,29 +6,53 @@ use super::super::*; use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return}; use crate::DocState; +/// The formats Save writes, in the order save dialogs offer them; other formats are exports. +pub const SAVE_FORMATS: [&str; 4] = ["vectorcraft", "ai", "svg", "svgz"]; + /// The format Save writes: `format` (an id or extension), else the format the path's extension -/// names, else native. Save writes .vectorcraft, SVG and SVGZ; other formats are exports. +/// names, else native (see [`SAVE_FORMATS`]). A `.ai` file is a PDF carrying the native document. pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> { let f = match (format, path.and_then(format_for_name)) { - (Some(_), _) => writable_format(format, None)?, + (Some(id), _) => super::format(id).filter(|f| SAVE_FORMATS.contains(&f.id)).map_or_else(|| writable_format(format, None), Ok)?, (None, Some(f)) => f, (None, None) => super::format("vectorcraft").ok_or("no native format")?, }; - match f.id { - "vectorcraft" | "svg" | "svgz" => Ok(f), - _ => Err(format!("Save writes .{}, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)), + if SAVE_FORMATS.contains(&f.id) { + Ok(f) + } else { + Err(format!("Save writes .{}, .ai, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)) } } +/// Save dialog filters for a file named `name`: when Save writes its format, every format Save +/// writes (that one first), else none. +pub fn save_filters(name: &str) -> Vec<(&'static str, &'static [&'static str])> { + let Some(first) = format_for_name(name).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] }; + let others = SAVE_FORMATS.iter().filter_map(|id| super::format(id)).filter(|f| f.id != first.id); + std::iter::once(first).chain(others).map(|f| (f.label, f.extensions)).collect() +} + /// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and /// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the /// ones the document was last saved with, and keeps hidden layers (hidden) unless they say -/// otherwise. A native file records its links' paths relative to `path`. +/// otherwise. A native file (and the document a `.ai` file carries) records its links' paths +/// relative to `path`. pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> { const C: &str = "document.save"; - if f.id == "vectorcraft" { - let relative = path.and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path)); - return Ok((encode_all(relative.as_ref().unwrap_or(&st.doc), f.id, p)?, Value::Null)); + let relative = path.filter(|_| matches!(f.id, "vectorcraft" | "ai")).and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path)); + let doc = relative.as_ref().unwrap_or(&st.doc); + match f.id { + "vectorcraft" => return Ok((encode_all(doc, f.id, p)?, Value::Null)), + // A PDF of every artboard with the PDF options in `p`, always carrying the native document. + "ai" => { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); + } + let (bytes, warnings) = super::pdf::encode(C, &doc.without_edit_modes(), &q)?; + return Ok((Encoded { warnings, ..Encoded::one(bytes) }, Value::Null)); + } + _ => {} } let given = super::svg_options(p).map_err(|e| bad(C, e))?; let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) }; @@ -57,18 +81,22 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { if path.is_some() { stamp_save_dates(s.doc_mut()?); } + let expanded = super::pdf::expand_preset(s, C, p)?; + let p = &*expanded; let st = s.doc()?; let (enc, opts) = save_encoding(st, f, p, path.as_deref())?; + // The encoder's notes (a .ai file's PDF options not applied yet…). + let notes = if enc.warnings.is_empty() { json!({}) } else { json!({ "warnings": enc.warnings }) }; let Some(path) = path else { // Nowhere to save to (web, agents): hand the bytes back; the document stays modified. - return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({})); + return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, notes); }; let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?; let st = s.doc_mut()?; st.path = Some(path.clone()); st.save_options = opts; st.mark_saved(); - let mut r = json!({ "path": path }); + let mut r = super::merge(json!({ "path": path }), notes); if let Some(linked) = out.get("linked") { r["linked"] = linked.clone(); } diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs new file mode 100644 index 00000000..1d5fba70 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -0,0 +1,170 @@ +//! PDF and .ai files that reopen as they were: Preserve Editing embeds the native document, which +//! `document.open` restores unless the pages changed elsewhere; `.ai` saves always carry it and +//! `.ait` templates open from it untitled. + +use serde_json::{Value, json}; + +use super::tests_svgedit::{comparable, rich}; +use super::*; + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap() +} + +fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +fn tmp(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("vc-pdfedit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name).to_string_lossy().to_string() +} + +/// `pdf` with `from` replaced by `to` (the same length). +fn patched(pdf: &[u8], from: &[u8], to: &[u8]) -> Vec { + assert_eq!(from.len(), to.len()); + let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from))); + let mut out = pdf.to_vec(); + out[at..at + to.len()].copy_from_slice(to); + out +} + +#[test] +fn a_pdf_saved_with_preserve_editing_reopens_as_the_same_document() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + // On in the default preset. + let v = s.execute("document.exportPdf", &json!({})).unwrap(); + assert_eq!(v["warnings"].as_array().map(|w| w.iter().any(|w| w.as_str().unwrap().contains("Preserve"))), Some(false), "{v}"); + let pdf = b64(&v); + let r = open(&mut s, "rich.pdf", &pdf); + assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before, "the document came back exactly"); + assert_eq!(s.doc().unwrap().path, None, "a PDF is not saved over"); + // Picking pages imports them as artwork. + let r = s.execute("document.open", &json!({"name": "rich.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf), "pages": "1"})).unwrap(); + assert_eq!(r["restored"], false, "{r}"); + // Off: the pages import as artwork. + let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap()); + assert!(vectorcraft_pdf::editing(&plain).is_none()); + assert_eq!(open(&mut s, "plain.pdf", &plain)["restored"], false); + // document.export and serialize take the same option. + let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact)); +} + +#[test] +fn pages_changed_elsewhere_open_as_artwork_with_a_warning() { + let mut s = rich(); + let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap()); + // Another app changes the artboard's height (300 × 200 pt). + let edited = patched(&pdf, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]"); + let r = open(&mut s, "edited.pdf", &edited); + assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{r}"); + assert!(s.doc().unwrap().doc.symbols.is_empty(), "plain import: no symbols"); + // Editing data that isn't a native document can't be read. + let damaged = patched(&pdf, b"{\"format\":\"vectorcraft\"", b"{\"format\":\"vectorcrafX\""); + let r = open(&mut s, "damaged.pdf", &damaged); + assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}"); +} + +#[test] +fn the_legacy_payload_name_is_accepted() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap()); + let (name, legacy) = (format!("({})", vectorcraft_pdf::EDITING_FILE), format!("({})", vectorcraft_pdf::LEGACY_EDITING_FILE)); + let legacy = format!("{legacy}{}", " ".repeat(name.len() - legacy.len())); + let mut old = pdf.clone(); + while let Some(at) = old.windows(name.len()).position(|w| w == name.as_bytes()) { + old[at..at + name.len()].copy_from_slice(legacy.as_bytes()); + } + assert!(!String::from_utf8_lossy(&old).contains(vectorcraft_pdf::EDITING_FILE)); + assert_eq!(open(&mut s, "old.pdf", &old)["restored"], true); + assert_eq!(comparable(&s.doc().unwrap().doc), before); +} + +#[test] +fn ai_files_are_pdfs_that_save_and_reopen_editable() { + let mut s = rich(); + let path = tmp("art.ai"); + let r = s.execute("document.save", &json!({"path": path, "preserveEditing": false, "range": "1"})).unwrap(); + assert_eq!(r["path"], path.as_str()); + // As saved (Save stamps the File Info dates). + let before = comparable(&s.doc().unwrap().doc); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF"), ".ai is PDF-compatible"); + assert!(vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "always carries the document"); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.as_str()), "the document takes the path"); + // Reopening restores it and keeps the path: Save writes .ai again. + let r = s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.as_str())); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers); + // PDF stays an export. + assert!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).is_err(), "PDF is written with document.exportPdf"); + // A plain PDF named .ai opens as artwork, without a path to save over. + let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap()); + let plain_path = tmp("plain.ai"); + std::fs::write(&plain_path, plain).unwrap(); + let r = s.execute("document.open", &json!({"path": plain_path})).unwrap(); + assert_eq!(r["restored"], false); + assert_eq!(s.doc().unwrap().path, None); +} + +#[test] +fn ait_templates_open_their_document_untitled() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + // A never-saved document's .ai comes back as bytes. + let ai = b64(&s.execute("document.save", &json!({"format": "ai"})).unwrap()); + assert!(ai.starts_with(b"%PDF")); + let r = open(&mut s, "brochure.ait", &ai); + assert_eq!((&r["format"], &r["restored"]), (&json!("ait"), &json!(true)), "{r}"); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(s.doc().unwrap().path, None); + assert_eq!(comparable(&s.doc().unwrap().doc), before); +} + +#[test] +fn standards_and_screens_leave_the_editing_data_out() { + let mut s = rich(); + // Choosing PDF/A turns Preserve Editing off unless it is asked for, which PDF/A refuses. + let pdf_a = b64(&s.execute("document.exportPdf", &json!({"standard": "pdfA2b"})).unwrap()); + assert!(vectorcraft_pdf::editing(&pdf_a).is_none()); + let e = s.execute("document.exportPdf", &json!({"standard": "pdfA2b", "preserveEditing": true})).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "{e}"); + // Export for Screens writes a PDF per artboard for viewing. + let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "pdf"}]})).unwrap(); + let file = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap(); + assert!(vectorcraft_pdf::editing(&file).is_none()); +} + +#[test] +fn save_dialogs_offer_every_format_save_writes() { + let labels = |name: &str| save_filters(name).into_iter().map(|(l, _)| l).collect::>(); + assert_eq!(labels("a.ai")[0], "PDF-compatible .ai"); + assert_eq!(labels("a.vectorcraft").len(), SAVE_FORMATS.len()); + assert_eq!(labels("a.svg")[0], "SVG"); + assert!(labels("a.png").is_empty() && labels("noext").is_empty(), "exports pick their own name"); + assert_eq!(save_format(Some("ai"), None).unwrap().id, "ai"); + assert_eq!(save_format(None, Some("x.AI")).unwrap().id, "ai"); + assert!(save_format(Some("png"), None).is_err()); +} + +#[test] +fn a_pdf_of_some_artboards_carries_no_editing_data() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap(); + for (range, kept) in [("2", false), ("1,3", false), ("3,2,1", false), ("1-3", true), ("all", true)] { + let v = s.execute("document.exportPdf", &json!({ "range": range })).unwrap(); + assert_eq!(vectorcraft_pdf::editing(&b64(&v)).is_some(), kept, "{range}"); + let warned = v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::EDITING_NEEDS_EVERY_ARTBOARD); + assert_eq!(warned, !kept, "{range}: {v}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_svgedit.rs b/crates/engine/src/cmd/fileio/tests_svgedit.rs index 5e2e54cc..be91967e 100644 --- a/crates/engine/src/cmd/fileio/tests_svgedit.rs +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -21,7 +21,7 @@ fn select(s: &mut Session, ids: &[u64]) { } /// A roughened square, three instances of a symbol and a square filled with a pattern. -fn rich() -> Session { +pub(super) fn rich() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); let rough = rect(&mut s, 10.0, 10.0); @@ -46,7 +46,7 @@ fn open(s: &mut Session, name: &str, text: &str) -> Value { } /// `d` without what opening a file sets anew. -fn comparable(d: &Document) -> Document { +pub(super) fn comparable(d: &Document) -> Document { let mut d = d.clone(); d.title.clear(); d diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index efd5b445..85d233b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -364,6 +364,10 @@ pub struct Prefs { /// New Document → Recent: the settings of the last documents made (newest first). #[serde(skip_serializing_if = "Vec::is_empty")] pub recent_new_docs: Vec, + /// Edit → PDF Presets: the user's presets (the built-in ones aren't stored). A local library, + /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pdf_presets: Vec, } impl Default for Prefs { @@ -489,6 +493,7 @@ impl Default for Prefs { color_themes: vec![], new_doc_presets: vec![], recent_new_docs: vec![], + pdf_presets: vec![], } } } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7096923b..8fdfdb74 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -533,3 +533,33 @@ proptest! { survive("mutated saved svg", || vectorcraft_engine::cmd::fileio::load("saved.svg", svg.as_bytes()).ok().map(|l| l.doc))?; } } + +// ---------- PDF saved with editing data ---------- + +/// The rich document saved as a PDF-compatible .ai (the native document embedded, uncompressed). +fn saved_ai() -> Vec { + static AI: std::sync::OnceLock> = std::sync::OnceLock::new(); + AI.get_or_init(|| { + let r = rich_session().execute("document.save", &json!({"format": "ai", "compression": {"compressText": false}})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + }) + .clone() +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn ai_mutated_save_never_panics( + cut in 0usize..200_000, + flips in prop::collection::vec((0usize..200_000, prop::sample::select(vec![b'0', b'9', b'-', b'.', b' ', b'[', b']', b'<', b'>', b'/', b'(', b'{', b'"', 0u8, 0xff])), 0..10), + ) { + let mut b = saved_ai(); + for (i, c) in flips { + let n = b.len(); + b[i % n] = c; + } + b.truncate(cut.min(b.len()).max(9)); + survive("mutated ai", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc))?; + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 4735884b..da5a02de 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -26,8 +26,12 @@ impl Default for Headless { /// `run_command` behaves the same in both modes. const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"), - ("file.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"), - ("file.saveAs", "Save As…", "{path}"), + ( + "file.save", + "Save", + "{path?, format?} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .svg or .svgz (default: the document's path)", + ), + ("file.saveAs", "Save As…", "{path, format?, …the format's options} = document.save"), ("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"), ("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs new file mode 100644 index 00000000..32973d8e --- /dev/null +++ b/crates/pdf/src/editing.rs @@ -0,0 +1,123 @@ +//! Preserve Editing: the native document travels inside the PDF as an embedded file, with a hash +//! of the pages it was written with, so a PDF whose pages another app changed is noticed (and +//! opens as its artwork instead of the stale native document). + +use std::sync::Arc; + +use hayro_syntax::Pdf; +use hayro_syntax::object::{Array, Dict, Object, Stream, String as PdfString}; +use krilla::embed::{AssociationKind, EmbeddedFile, MimeType}; +use krilla::metadata::DateTime; + +use crate::PdfError; +use crate::lab_spot::find; + +/// The embedded file holding the native document (its name among the PDF's attachments). +pub const EDITING_FILE: &str = "vectorcraft-editing.vectorcraft"; +/// The editing file's name from before the project was renamed: still read. +pub const LEGACY_EDITING_FILE: &str = "drawcraft-editing.drawcraft"; + +/// The editing file's description: this text, then the hash of the pages. +const DESCRIPTION: &str = "VectorCraft editing data; page hash "; +/// Stands for the hash while the PDF is written: as long as a hash, so writing the hash over it +/// keeps every offset. +const PLACEHOLDER: &str = "0000000000000000"; +/// Deepest name tree searched for the editing file. +const MAX_DEPTH: usize = 16; + +/// The native document a PDF carries ([`crate::PdfSettings::preserve_editing`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Editing { + /// The native document's bytes (empty when they can't be decoded). + pub data: Vec, + /// The pages are still the ones written with it: the hash of their size and content matches. + /// False once another app changed them; true for editing data written without a hash. + pub intact: bool, +} + +/// The embedded file for `native`, its description holding the hash placeholder [`seal`] fills. +pub(crate) fn embedded_file(native: &[u8], compress: bool, date: Option) -> EmbeddedFile { + EmbeddedFile { + path: EDITING_FILE.into(), + mime_type: MimeType::new("application/json"), + description: Some(format!("{DESCRIPTION}{PLACEHOLDER}")), + association_kind: AssociationKind::Source, + data: native.to_vec().into(), + modification_date: date, + compress: Some(compress), + location: None, + } +} + +/// Write the hash of the pages of `pdf` (written with an [`embedded_file`]) over the placeholder. +pub(crate) fn seal(pdf: Vec) -> Result, PdfError> { + let data = Arc::new(pdf); + let hash = page_hash(&Pdf::new(data.clone()).map_err(|e| PdfError::Write(format!("can't read back the written PDF: {e:?}")))?); + let mut pdf = Arc::try_unwrap(data).unwrap_or_else(|d| (*d).clone()); + let at = find(&pdf, format!("({DESCRIPTION}{PLACEHOLDER})").as_bytes(), 0).map(|i| i + 1 + DESCRIPTION.len()); + let slot = + at.and_then(|i| pdf.get_mut(i..i + PLACEHOLDER.len())).ok_or_else(|| PdfError::Write("the editing data's hash has no place".into()))?; + slot.copy_from_slice(hash.as_bytes()); + Ok(pdf) +} + +/// The native document `bytes` (a PDF) carries, if any, and whether its pages changed since. +pub fn editing(bytes: &[u8]) -> Option { + editing_in(&Pdf::new(bytes.to_vec()).ok()?) +} + +/// [`editing`] of a parsed PDF. +pub(crate) fn editing_in(pdf: &Pdf) -> Option { + let xref = pdf.xref(); + let catalog = xref.get::>(xref.root_id())?; + let tree = catalog.get::>(b"Names")?.get::>(b"EmbeddedFiles")?; + let spec = editing_spec(&tree, 0)?; + let files = spec.get::>(b"EF")?; + let stream = files.get::>(b"F").or_else(|| files.get::>(b"UF"))?; + let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default(); + // The hash ends the description; editing data described otherwise is trusted. + let desc = spec.get::>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default(); + let intact = match desc.rsplit_once("hash ") { + Some((_, hash)) => hash.trim() == page_hash(pdf), + None => true, + }; + Some(Editing { data, intact }) +} + +/// The file specification of the editing file in the name tree node `node`. +fn editing_spec<'a>(node: &Dict<'a>, depth: usize) -> Option> { + if let Some(names) = node.get::>(b"Names") { + let mut items = names.iter::>(); + while let (Some(_), Some(spec)) = (items.next(), items.next()) { + if let Object::Dict(spec) = spec + && is_editing_file(&spec) + { + return Some(spec); + } + } + } + if depth >= MAX_DEPTH { + return None; + } + node.get::>(b"Kids")?.iter::>().find_map(|kid| editing_spec(&kid, depth + 1)) +} + +/// A file specification naming the editing file (current or legacy name). +fn is_editing_file(spec: &Dict<'_>) -> bool { + [&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::>(k)).any(|name| { + let name = name.as_bytes(); + name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes() + }) +} + +/// FNV-1a of the pages' media boxes and decoded content, as 16 hex digits. +fn page_hash(pdf: &Pdf) -> String { + let fnv = |h: u64, bytes: &[u8]| bytes.iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3)); + let mut h = 0xcbf29ce484222325; + for page in pdf.pages().iter() { + let b = page.media_box(); + h = fnv(h, format!("[{} {} {} {}]", b.x0, b.y0, b.x1, b.y1).as_bytes()); + h = fnv(h, page.page_stream().unwrap_or_default()); + } + format!("{h:016x}") +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 17ab2040..a784ed70 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -81,10 +81,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result Result