diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 37334fef..03e2358a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -293,9 +293,9 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result { AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type")) } -/// The `useArtboards` param of a PDF, DXF or raster export (SVG has its own): `true` writes every +/// The `useArtboards` param of a PDF, AI, DXF or raster export (SVG has its own): `true` writes every /// chosen artboard (default all), `false` the bounds of the visible art instead, absent one -/// artboard (raster, DXF) or the chosen pages (PDF). +/// artboard (raster, DXF) or the chosen pages (PDF, AI). fn use_artboards(p: &Value) -> Result> { match p.get("useArtboards") { None | Some(Value::Null) => Ok(None), @@ -364,9 +364,10 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<( /// [`encode`], with every file the export writes. pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { - // Placed documents output their files' art (a native file keeps what it keeps: see - // `native::encode`). - if !matches!(format, "vectorcraft" | "template") { + let format = super::writable(C, Some(format), None)?.id; + // Formats with editing data prepare their drawn output separately from their original + // document. Other formats only need the placed files' art. + if !matches!(format, "vectorcraft" | "template" | "ai" | "pdf" | "eps" | "svg" | "svgz") { let (full, warnings) = crate::cmd::place::document::full_documents(doc); if !warnings.is_empty() { let mut enc = encode_all_of(&full, format, p)?; @@ -397,7 +398,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; if use_artboards == Some(false) { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); @@ -417,16 +418,11 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "dxf" => return super::dxf::encode(doc, p, use_artboards), - // As Save As writes it (a PDF of every artboard carrying the native document). - "ai" => { - let mut q = p.clone(); - super::save::ai_params(&mut q); - return super::save::encode_ai(C, f, doc, &q); - } // EPS reads its own `useArtboards` (the art's bounds unless asked). "eps" => return super::eps::encode(doc, p), "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), "wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf), + "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 31953251..4c958171 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -255,6 +255,17 @@ fn native(doc: &Document, board: Option, include_linked: bool) -> Vec /// itself. pub(super) fn encode(doc: &Document, p: &Value) -> Result { let set = parse(p)?; + let original = doc; + let (full, warnings) = crate::cmd::place::document::full_documents(doc); + let doc = &*full; + // Include available bytes while retaining missing, relinkable objects in the attachment. + let editing = if set.include_linked { + let mut d = original.clone(); + d.images = doc.images.clone(); + Cow::Owned(d) + } else { + Cow::Borrowed(original) + }; let pages = pages(doc, &set)?; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); @@ -262,7 +273,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { let transparent = doc.layers.iter().any(|l| l.shows_transparency()); let flat = flatten_document(doc, &set.flatten)?; let drawn = flat.as_ref().unwrap_or(doc); - let mut enc = Encoded { joiner: Some("_"), ..Encoded::default() }; + let mut enc = Encoded { warnings, joiner: Some("_"), ..Encoded::default() }; if transparent && flat.is_some() { enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); } @@ -275,8 +286,14 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?), }; let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?; - let o = - EpsOptions { region, origin, title: doc.title.clone(), created, native: Some(native(doc, board, set.include_linked)), ..set.eps.clone() }; + let o = EpsOptions { + region, + origin, + title: doc.title.clone(), + created, + native: Some(native(&editing, board, set.include_linked)), + ..set.eps.clone() + }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; enc.files.push((board, out.bytes)); for w in out.warnings { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 416b98a2..98b059d4 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -245,7 +245,7 @@ const ARTBOARDS: FormatOption = FormatOption { name: "artboards", ty: "array", default: "null", - description: "0-based artboards (PDF: one page each, default all; SVG, and raster formats with useArtboards: one file each)", + description: "0-based artboards (PDF and AI: one page each, default all; SVG, and raster formats with useArtboards: one file each)", }; const RANGE: FormatOption = FormatOption { name: "range", @@ -260,7 +260,7 @@ const USE_ARTBOARDS: FormatOption = FormatOption { name: "useArtboards", ty: "boolean", default: "null", - description: "true: every chosen artboard (default all), one file each named -. (PDF: one page each); false: the bounds of the visible art", + description: "true: every chosen artboard (default all), one file each named -. (PDF and AI: one page each); false: the bounds of the visible art", }; const PPI: FormatOption = FormatOption { name: "ppi", diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index e38e73f6..289bfcff 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -3,6 +3,7 @@ //! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create //! PDF-Compatible File. +use std::borrow::Cow; use std::collections::BTreeMap; use serde::Deserialize; @@ -25,8 +26,8 @@ pub const OPTIONS: &[FormatOption] = &[ FormatOption { name: "version", ty: "integer", - default: "3", - description: "the format version to write: 3 (current), or 2 or 1 for older VectorCraft versions (not compressed; newer features they don't know are lost there)", + default: "4", + description: "the format version to write: 4 (current), or 3, 2 or 1 for older VectorCraft versions (1 and 2 are never compressed; newer features they don't know are lost there)", }, FormatOption { name: "preview", @@ -71,11 +72,19 @@ const EMBED_PROFILES: FormatOption = FormatOption { description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed", }; -/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on -/// top, see document.exportPdf). +/// The save and export options of a `.ai` file (a PDF carrying the native document; its PDF +/// options come on top, see document.exportPdf). Saves always retain every artboard. pub const AI_OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::USE_ARTBOARDS, + FormatOption { + name: "range", + ty: "string", + default: "null", + description: "1-based artboards such as \"1-3, 5\", or \"all\": the PDF pages to export, or with separateArtboards the files to save separately", + }, SEPARATE_ARTBOARDS, - SEPARATE_RANGE, INCLUDE_LINKED, EMBED_PROFILES, FormatOption { @@ -133,15 +142,23 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } else { std::borrow::Cow::Borrowed(doc) }; - let doc = &*full; if extras.preview { - so.preview = preview_png(doc)?; + so.preview = preview_png(&full)?; } if extras.pdf { // Every artboard as PDF pages (no editing data: the file is the native document). - so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0); + so.pdf = Some(super::pdf::encode(cmd, &full, &json!({ "range": "all", "preserveEditing": false }))?.0); } - vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string())) + let native = match full { + Cow::Owned(full) if so.include_linked && so.version >= vectorcraft_format::VERSION => { + // Include available bytes without replacing missing, relinkable placed objects. + let mut original = doc.clone(); + original.images = full.images; + Cow::Owned(original) + } + other => other, + }; + vectorcraft_format::save_with(&native, &so).map_err(|e| bad(cmd, e.to_string())) } /// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in @@ -172,16 +189,40 @@ pub(super) fn install_profiles(profiles: BTreeMap>) -> Vec Result> { +/// Check AI options even when chosen PDF pages omit the native editing attachment. +pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { let mut q = p.clone(); if let Some(o) = q.as_object_mut() { - o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible")); + // AI carries the current native format without a separate preview. Its compression + // and PDF-content flags are still validated as booleans. + o.retain(|k, _| !matches!(k.as_str(), "version" | "preview")); + if o.get("pdfCompatible").is_some_and(Value::is_null) { + o.remove("pdfCompatible"); + } } let (mut so, _) = save_options(cmd, f, doc, &q)?; so.pretty = false; - vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string())) + so.compress = false; // The PDF compresses its editing attachment. + Ok(so) +} + +/// The original native document carried by an AI file, with validated save options. +pub(super) fn ai_native(cmd: &str, doc: &Document, so: &SaveOptions) -> Result> { + let native = if so.include_linked { + match crate::cmd::place::document::full_documents(doc).0 { + Cow::Borrowed(d) => Cow::Borrowed(d), + Cow::Owned(full) => { + // Include available files, retaining placed objects even when their files + // are missing. Only drawn output substitutes unlinked preview images. + let mut original = doc.clone(); + original.images = full.images; + Cow::Owned(original) + } + } + } else { + Cow::Borrowed(doc) + }; + vectorcraft_format::save_with(&native, so).map_err(|e| bad(cmd, e.to_string())) } /// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 6052e83b..6f5a0da5 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -249,7 +249,11 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { - encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false))) + // 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)))?; + warnings.extend(drawn_warnings); + Ok((bytes, warnings)) } /// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 94b6571a..36c98d0e 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -353,36 +353,41 @@ fn blank_pages(doc: &Document) -> Document { /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; -/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard -/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers -/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content. -pub(super) fn ai_params(params: &mut Value) { - let Some(o) = params.as_object_mut() else { return }; - o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); - // Apps that read the PDF part (not the native document) then find the layers, the hidden ones - // with their art (#372); a PDF standard decides for itself. - if o.get("standard").is_none_or(Value::is_null) { - o.entry("createLayers").or_insert(json!(true)); - } - if let Some(c) = o.get("compress").and_then(Value::as_bool) { - let compression = o.entry("compression").or_insert_with(|| json!({})); - if let Some(m) = compression.as_object_mut() { - m.insert("compressText".into(), json!(c)); +/// The shared Save and Export encoder for PDF-compatible `.ai` files. +pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = params.as_object_mut() { + o.insert("preserveEditing".into(), json!(true)); + // Keep upstream AI PDF layers, including hidden layers, unless a standard decides. + if o.get("standard").is_none_or(Value::is_null) { + o.entry("createLayers").or_insert(json!(true)); + } + if let Some(c) = o.get("compress").and_then(Value::as_bool) { + let compression = o.entry("compression").or_insert_with(|| json!({})); + if let Some(m) = compression.as_object_mut() { + m.insert("compressText".into(), json!(c)); + } + } + } + let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; + let pdf_compatible = bool_or(¶ms, "pdfCompatible", true); + if !pdf_compatible { + let picked: ArtboardPick = super::encode::options(f, ¶ms)?; + let picked = picked.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; + if picked.is_some_and(|v| !v.into_iter().eq(0..doc.artboards.len())) { + return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork")); } } -} - -/// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command -/// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native -/// document. File › Save As writes it, and so do the exports (`document.export`, the CLI). -pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result { - let compatible = bool_or(params, "pdfCompatible", true); let pages = doc.without_edit_modes(); - let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; - let native = || super::native::ai_native(cmd, f, doc, params); - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?; - if !compatible { - warnings.insert(0, NOT_PDF_COMPATIBLE.to_string()); + // Resolve placed files only for the drawn pages. Missing files become preview images there; + // the native attachment keeps the original placed objects and their relinkable metadata. + let (pages, placed_warnings) = + if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) }; + let native = || super::native::ai_native(cmd, doc, &native_options); + let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native)?; + warnings.extend(placed_warnings); + if !pdf_compatible { + warnings.push(NOT_PDF_COMPATIBLE.to_string()); } Ok(Encoded { warnings, ..Encoded::one(bytes) }) } @@ -421,8 +426,9 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 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), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 1a3cf8e3..de130666 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -153,10 +153,14 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result 0, "the missing file draws its preview"); + assert!(out["warnings"].to_string().contains("output as their previews")); + } else { + assert_eq!(images, 0, "the PDF uses the file's vectors"); + assert!(paths > 0); + } + } + let mut restored = session(); + let result = restored + .execute("document.open", &json!({"name": "export.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})) + .unwrap(); + assert_eq!(result["restored"], true); + assert_eq!(placed(&restored, id), original, "link metadata stays editable"); + assert_eq!(placed(&s, id), original, "export leaves the source intact"); + if missing { + assert_eq!(restored.execute("links.check", &json!({})).unwrap()["missing"], 1); + let replacement = dir.file("replacement.vectorcraft"); + source(&replacement, 100.0, 50.0, BLUE, 1); + let relinked = restored.execute("links.relink", &json!({"ids": [id.0], "path": replacement})).unwrap(); + assert_eq!(relinked["relinked"], json!([id.0])); + assert_eq!(placed(&restored, id).link.path, replacement); + } + } + } + } +} + +#[test] +fn ai_export_public_aliases_preserve_missing_placed_links() { + let dir = Folder::new("placed-ai-public-aliases-latest"); + let src = dir.file("logo.vectorcraft"); + star_source(&src, "placed-ai-public-aliases-latest-unique"); + let mut s = session(); + let id = place(&mut s, &src); + let parent = dir.file("parent.vectorcraft"); + save(&mut s, &parent); + std::fs::remove_file(&src).unwrap(); + open(&mut s, &parent); + let original = placed(&s, id); + assert!(s.doc().unwrap().doc.images[&original.key].is_proxy()); + + for alias in + ["vectorcraft", ".vectorcraft", "VECTORCRAFT", "drawcraft", ".DRAWCRAFT", "template", "vctemplate", ".VCTEMPLATE", "ai", ".ai", "AI", ".AI"] + { + let canonical = cmd::fileio::format(alias).unwrap().id; + let enc = cmd::fileio::encode_all(&s.doc().unwrap().doc, alias, &json!({"pdfCompatible": false, "includeLinked": false})).unwrap(); + let bytes = &enc.files[0].1; + let raw = if canonical == "ai" { vectorcraft_pdf::editing(bytes).unwrap().data } else { bytes.clone() }; + let native = vectorcraft_format::load(&raw).unwrap(); + assert!( + matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), + "{alias}: preparation discarded the editable placed link" + ); + assert!(native.images[&original.key].is_proxy(), "{alias}"); + assert_eq!(placed(&s, id), original, "{alias}: source changed"); + } +} + +/// Include Linked Files must retain relinkable objects when only their previews are available. +#[test] +fn native_included_links_keep_missing_placed_documents_editable() { + for missing in [true, false] { + let dir = Folder::new(&format!("native-included-links-{missing}")); + let src = dir.file("logo.vectorcraft"); + star_source(&src, &format!("native-included-links-{missing}")); + let source_bytes = std::fs::read(&src).unwrap(); + let mut s = session(); + let id = place(&mut s, &src); + let parent = decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap()); + if missing { + std::fs::remove_file(&src).unwrap(); + } + s.execute("document.open", &json!({"name": "parent.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&parent)})).unwrap(); + let original = placed(&s, id); + assert!(s.doc().unwrap().doc.images[&original.key].is_proxy()); + let before = s.execute("document.json", &json!({})).unwrap(); + for format in ["vectorcraft", "template"] { + for command in ["document.serialize", "document.export", "document.save"] { + for include_linked in [true, false] { + let out = s.execute(command, &json!({"format": format, "includeLinked": include_linked, "compress": false})).unwrap(); + let bytes = decode(&out); + let native = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(file_json(&bytes)["version"], vectorcraft_format::VERSION); + assert!( + matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), + "{command}/{format}/{missing}/{include_linked}: discarded the editable link" + ); + let blob = &native.images[&original.key]; + assert_eq!(blob.is_proxy(), missing || !include_linked); + if !missing && include_linked { + assert_eq!(blob.bytes.as_slice(), source_bytes.as_slice()); + } + assert_eq!(s.execute("document.json", &json!({})).unwrap(), before); + if missing && include_linked { + let mut restored = Session::new(); + restored + .execute( + "document.open", + &json!({"name": "included.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), + ) + .unwrap(); + assert_eq!(placed(&restored, id), original); + assert_eq!(restored.execute("links.check", &json!({})).unwrap()["missing"], 1); + let replacement = dir.file("replacement.vectorcraft"); + source(&replacement, 100.0, 50.0, BLUE, 1); + assert_eq!( + restored.execute("links.relink", &json!({"ids": [id.0], "path": replacement})).unwrap()["relinked"], + json!([id.0]) + ); + assert_eq!(placed(&restored, id).link.path, replacement); + } + } + } + } + } +} + #[test] fn a_document_saved_with_previews_outputs_its_files_art() { let dir = Folder::new("placed-preview-output"); @@ -479,3 +627,256 @@ fn link_info_gives_the_artboards_size_not_pixels() { "{i}" ); } + +/// Save As reads its defaults from the catalog; using them must retain current editable links. +#[test] +fn native_save_catalog_defaults_keep_placed_documents_editable() { + for missing in [false, true] { + let dir = Folder::new(if missing { "native-default-missing" } else { "native-default-linked" }); + let src = dir.file("logo.vectorcraft"); + star_source(&src, if missing { "native-default-missing" } else { "native-default-linked" }); + let mut s = session(); + let id = place(&mut s, &src); + let original = placed(&s, id); + if missing { + std::fs::remove_file(&src).unwrap(); + } + let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap(); + let saved = dir.file("default-options.vectorcraft"); + s.execute("document.save", &json!({"path": saved, "version": options["options"]["version"]["value"]})).unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + let native = vectorcraft_format::load(&bytes).unwrap(); + let mut written = original.clone(); + // Native saves record a sibling link relative to the destination as well as its + // absolute path. This is relocation metadata, not a change to the placed object. + written.link.relative = Some("logo.vectorcraft".into()); + assert!(matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == written), "source missing: {missing}"); + assert_eq!(file_json(&bytes)["version"], vectorcraft_format::VERSION); + assert_eq!(placed(&s, id), original, "saving does not rewrite the live link"); + let mut reopened = Session::new(); + open(&mut reopened, &saved); + assert_eq!(placed(&reopened, id).link.path, src); + assert_eq!(reopened.execute("links.check", &json!({})).unwrap()["missing"], usize::from(missing)); + } +} + +/// Generic and dedicated PDF exports share editable links and prepared drawn pages. +#[test] +fn pdf_editing_keeps_placed_links_separate_from_drawn_previews() { + for missing in [true, false] { + let dir = Folder::new(&format!("pdf-editable-links-{missing}")); + let src = dir.file("logo.vectorcraft"); + star_source(&src, &format!("pdf-editable-links-{missing}")); + let mut s = session(); + parent_star(&mut s); + let id = place(&mut s, &src); + s.execute("artboard.new", &json!({"x":600,"y":0,"width":100,"height":100})).unwrap(); + let parent = decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap()); + if missing { + std::fs::remove_file(&src).unwrap(); + } + s.execute("document.open", &json!({"name": "parent.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&parent)})).unwrap(); + let original = placed(&s, id); + assert!(s.doc().unwrap().doc.images[&original.key].is_proxy()); + let before = s.execute("document.json", &json!({})).unwrap(); + for command in ["document.serialize", "document.export", "document.save", "document.exportPdf"] { + let out = s.execute(command, &json!({"format":"pdf","preserveEditing":true,"range":"all","thumbnails":false})).unwrap(); + let bytes = decode(&out); + let editing = vectorcraft_pdf::editing(&bytes).unwrap(); + assert!(editing.intact); + let native = vectorcraft_format::load(&editing.data).unwrap(); + assert!(matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), "{command}: discarded editable link"); + let (images, paths) = pdf_contents(&bytes); + assert_eq!(images > 0, missing, "{command}: pages still draw previews or vectors"); + if !missing { + assert!(paths > 0); + } + assert_eq!( + out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("placed document(s) output as their previews")), + missing, + "{out}" + ); + let mut restored = Session::new(); + assert_eq!( + restored.execute("document.open", &json!({"name":"editable.pdf","dataBase64":vectorcraft_format::base64_encode(&bytes)})).unwrap()["restored"], + true + ); + assert_eq!(placed(&restored, id), original); + assert_eq!(restored.execute("links.check", &json!({})).unwrap()["missing"], usize::from(missing)); + assert_eq!(s.execute("document.json", &json!({})).unwrap(), before); + if missing { + let replacement = dir.file("replacement.vectorcraft"); + source(&replacement, 100.0, 50.0, BLUE, 1); + assert_eq!(restored.execute("links.relink", &json!({"ids":[id.0],"path":replacement})).unwrap()["relinked"], json!([id.0])); + } + } + for alias in ["PDF", ".pdf"] { + let enc = cmd::fileio::encode_all(&s.doc().unwrap().doc, alias, &json!({"preserveEditing":true})).unwrap(); + let editing = vectorcraft_pdf::editing(&enc.files[0].1).unwrap(); + let native = vectorcraft_format::load(&editing.data).unwrap(); + assert!(matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), "{alias}"); + } + for params in [json!({"format":"pdf","preserveEditing":false}), json!({"format":"pdf","preserveEditing":true,"range":"1"})] { + let out = s.execute("document.serialize", ¶ms).unwrap(); + let bytes = decode(&out); + assert!(vectorcraft_pdf::editing(&bytes).is_none(), "{params}"); + assert_eq!(pdf_contents(&bytes).0 > 0, missing); + } + } +} + +/// EPS draws hydrated artwork but carries the original link metadata with requested source bytes. +#[test] +fn eps_editing_keeps_placed_links_and_requested_source_bytes() { + for missing in [true, false] { + let dir = Folder::new(&format!("eps-editable-links-{missing}")); + let src = dir.file("logo.vectorcraft"); + star_source(&src, &format!("eps-editable-links-{missing}")); + let source_bytes = std::fs::read(&src).unwrap(); + let mut s = session(); + parent_star(&mut s); + let id = place(&mut s, &src); + let parent = decode(&s.execute("document.serialize", &json!({"format":"vectorcraft"})).unwrap()); + if missing { + std::fs::remove_file(&src).unwrap(); + } + s.execute("document.open", &json!({"name":"parent.vectorcraft","dataBase64":vectorcraft_format::base64_encode(&parent)})).unwrap(); + let original = placed(&s, id); + assert!(s.doc().unwrap().doc.images[&original.key].is_proxy()); + let before = s.execute("document.json", &json!({})).unwrap(); + for include_linked in [false, true] { + let params = json!({"format":"eps","includeLinkedFiles":include_linked,"previewFormat":"none","thumbnails":false}); + for command in ["document.serialize", "document.export", "document.exportEps"] { + let out = s.execute(command, ¶ms).unwrap(); + let bytes = decode(&out); + let raw = vectorcraft_eps::native(&bytes).unwrap(); + let native = vectorcraft_format::load(&raw).unwrap(); + assert!( + matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), + "{command}/{missing}/{include_linked}: editable link lost" + ); + let blob = &native.images[&original.key]; + assert_eq!(blob.is_proxy(), missing || !include_linked); + if !missing && include_linked { + assert_eq!(blob.bytes.as_slice(), source_bytes.as_slice()); + } + assert_eq!( + out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("placed document(s) output as their previews")), + missing, + "{out}" + ); + // Read the printed page, independently of the native editing attachment. + let drawn = vectorcraft_eps::import_with(&bytes, false).unwrap(); + assert!(!drawn.preview); + let (mut images, mut paths) = (0, 0); + drawn.document.walk(|n| match n.kind { + NodeKind::Image(_) => images += 1, + NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1, + _ => {} + }); + assert_eq!(images > 0, missing, "{command}: drawn output uses vectors or the preview"); + if !missing { + assert!(paths > 0); + } + let mut restored = Session::new(); + assert_eq!( + restored + .execute("document.open", &json!({"name":"editable.eps","dataBase64":vectorcraft_format::base64_encode(&bytes)})) + .unwrap()["restored"], + true + ); + assert_eq!(placed(&restored, id), original); + assert_eq!(restored.execute("links.check", &json!({})).unwrap()["missing"], usize::from(missing)); + assert_eq!(s.execute("document.json", &json!({})).unwrap(), before); + if missing { + let replacement = dir.file("replacement.vectorcraft"); + source(&replacement, 100.0, 50.0, BLUE, 1); + assert_eq!(restored.execute("links.relink", &json!({"ids":[id.0],"path":replacement})).unwrap()["relinked"], json!([id.0])); + } + } + } + for alias in ["EPS", ".eps"] { + let enc = cmd::fileio::encode_all(&s.doc().unwrap().doc, alias, &json!({"previewFormat":"none","thumbnails":false})).unwrap(); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&enc.files[0].1).unwrap()).unwrap(); + assert!(matches!(&native.node(id).unwrap().kind, NodeKind::PlacedDocument(p) if **p == original), "{alias}"); + } + } +} + +/// An SVG's editing attachment keeps links even when its visible output must use previews. +#[test] +fn svg_editing_keeps_placed_links_separate_from_drawn_previews() { + for missing in [true, false] { + let dir = Folder::new(&format!("svg-editable-links-{missing}")); + let src = dir.file("logo.vectorcraft"); + star_source(&src, &format!("svg-editable-links-{missing}")); + let mut s = session(); + parent_star(&mut s); + let id = place(&mut s, &src); + let parent = decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap()); + if missing { + std::fs::remove_file(&src).unwrap(); + } + s.execute("document.open", &json!({"name": "parent.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&parent)})).unwrap(); + let original = placed(&s, id); + assert!(s.doc().unwrap().doc.images[&original.key].is_proxy()); + let before = s.execute("document.json", &json!({})).unwrap(); + for format in ["svg", "svgz"] { + for command in ["document.serialize", "document.export", "document.save"] { + let out = s.execute(command, &json!({"format": format, "svg": {"preserveEditing": true, "range": "1"}})).unwrap(); + let bytes = out + .get("dataBase64") + .map(|b| vectorcraft_format::base64_decode(b.as_str().unwrap()).unwrap()) + .unwrap_or_else(|| out["text"].as_str().unwrap().as_bytes().to_vec()); + let text = vectorcraft_svg::text_of(&bytes).unwrap(); + assert_eq!(text.contains(" impl Iterator { let range = f.options.iter().any(|o| o.name == "range"); let separate = separates(f); let options = if own_dialog(f) { &[][..] } else { f.options }; - options.iter().filter(move |o| !(matches!(o.ty, "array" | "object") || (range && o.name == "artboard") || (separate && o.name == "range"))) + options.iter().filter(move |o| { + !(matches!(o.ty, "array" | "object") + || (range && o.name == "artboard") + || (separate && o.name == "range") + || (f.id == "ai" && o.name == "useArtboards")) + }) } /// An option's label: the save options as the reference app's options dialog names them, the