Merge pull request #718 from sanyamsingh001/codex/file-interop-main

Preserve placed links in editable exports and honor AI artboard options
This commit is contained in:
BFlat 2026-10-12 07:20:04 +07:00 committed by GitHub
commit ec51bb3251
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 599 additions and 64 deletions

View File

@ -293,9 +293,9 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result<AntiAlias, String> {
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<Option<bool>> {
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<Encoded> {
// 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<Encoded> {
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<Encoded> {
"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) });

View File

@ -255,6 +255,17 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
/// itself.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
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<Encoded> {
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<Encoded> {
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 {

View File

@ -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 <file>-<artboard>.<ext> (PDF: one page each); false: the bounds of the visible art",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const PPI: FormatOption = FormatOption {
name: "ppi",

View File

@ -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<String, Vec<u8>>) -> Vec<Strin
.collect()
}
/// The native document a `.ai` file carries, with the options in `p` that apply to it
/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it).
pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
/// Check AI options even when chosen PDF pages omit the native editing attachment.
pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<SaveOptions> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
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<Vec<u8>> {
let native = if so.include_linked {
match crate::cmd::place::document::full_documents(doc).0 {
Cow::Borrowed(d) => Cow::Borrowed(d),
Cow::Owned(full) => {
// Include available files, retaining placed objects even when their files
// are missing. Only drawn output substitutes unlinked preview images.
let mut original = doc.clone();
original.images = full.images;
Cow::Owned(original)
}
}
} else {
Cow::Borrowed(doc)
};
vectorcraft_format::save_with(&native, so).map_err(|e| bad(cmd, e.to_string()))
}
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing

View File

@ -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<u8>, Vec<String>)> {
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

View File

@ -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<Encoded> {
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, &params)?;
let pdf_compatible = bool_or(&params, "pdfCompatible", true);
if !pdf_compatible {
let picked: ArtboardPick = super::encode::options(f, &params)?;
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<Encoded> {
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, &params, 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<Save
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if plan.format.id == "ai" {
ai_params(&mut params);
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead.
o.insert("range".into(), json!("all"));
}
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
}
@ -596,7 +602,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"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 <name>-<artboard>.<ext> 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 <name>-<artboard>.<ext> 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)
),

View File

@ -153,10 +153,14 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let (opts, boards, chosen) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
// Draw linked files as vectors when available, or their previews with a warning. Keep
// that preparation out of the native attachment so missing links remain relinkable.
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
let doc = &*full;
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded::default();
let mut enc = Encoded { warnings, ..Encoded::default() };
for artboard in boards {
// A chosen artboard's file holds the art over it (#550).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));

View File

@ -190,3 +190,64 @@ fn a_pdf_of_some_artboards_carries_no_editing_data() {
assert_eq!(warned, !kept, "{range}: {v}");
}
}
#[test]
fn ai_export_of_some_artboards_keeps_only_those_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
let r = s.execute("document.export", &json!({"format": "ai", "range": "2"})).unwrap();
let bytes = b64(&r);
assert!(vectorcraft_pdf::editing(&bytes).is_none());
assert!(r["warnings"].as_array().unwrap().contains(&json!(super::pdf::EDITING_NEEDS_EVERY_ARTBOARD)));
open(&mut s, "one.ai", &bytes);
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
let mut s = Session::new();
s.execute("file.new", &json!({"artboards": 3})).unwrap();
assert!(s.execute("document.export", &json!({"format": "ai", "range": "2", "pdfCompatible": false})).is_err());
}
#[test]
fn ai_export_validates_options_even_when_editing_data_is_omitted() {
let mut s = Session::new();
s.execute("file.new", &json!({"artboards": 3})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
for command in ["document.export", "document.serialize"] {
for range in ["all", "2"] {
for option in ["includeLinked", "embedProfiles", "compress", "pdfCompatible"] {
let mut params = json!({"format": "ai", "range": range});
params[option] = json!("no");
assert!(s.execute(command, &params).is_err(), "{command}: {params}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
assert!(
s.execute(
command,
&json!({"format": "ai", "range": range, "includeLinked": false, "embedProfiles": false, "compress": false, "pdfCompatible": true})
)
.is_ok()
);
}
}
}
#[test]
fn ai_export_without_artboards_keeps_art_outside_the_original_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 2})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 180, "y": 100, "width": 30, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 50, "height": 40})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
let r = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "range": "2"})).unwrap();
let bytes = b64(&r);
let pages = vectorcraft_pdf::import(&bytes).unwrap();
assert_eq!(pages.artboards.len(), 1);
assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0));
let editing = vectorcraft_pdf::editing(&bytes).unwrap();
let exported = vectorcraft_format::load(&editing.data).unwrap();
assert_eq!(exported.artboards.len(), 1);
assert_eq!(exported.artboards[0].rect, vectorcraft_geom::Rect::new(180.0, 100.0, 210.0, 120.0));
assert_eq!(comparable(&s.doc().unwrap().doc), before);
assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err());
}

View File

@ -198,6 +198,154 @@ fn the_preview_is_saved_so_the_document_shows_it_without_its_file() {
assert!(old.contains("\"image\"") && !old.contains("placeddocument"), "{old}");
}
#[test]
fn ai_export_preserves_placed_links_while_drawing_full_art_or_previews() {
for missing in [false, true] {
let dir = Folder::new(&format!("placed-ai-editable-{missing}"));
let src = dir.file("logo.vectorcraft");
star_source(&src, &format!("placed-ai-editable-{missing}"));
let mut s = session();
let id = place(&mut s, &src);
let parent = dir.file("parent.vectorcraft");
save(&mut s, &parent);
if missing {
std::fs::remove_file(&src).unwrap();
}
open(&mut s, &parent);
let original = placed(&s, id);
for pdf_compatible in [true, false] {
for (command, include_linked) in
[("document.export", false), ("document.export", true), ("document.serialize", false), ("document.serialize", true)]
{
let out = s.execute(command, &json!({"format": "ai", "pdfCompatible": pdf_compatible, "includeLinked": include_linked})).unwrap();
let bytes = decode(&out);
let editing = vectorcraft_pdf::editing(&bytes).unwrap();
let native = vectorcraft_format::load(&editing.data).unwrap();
assert_eq!(
native.images[&original.key].is_proxy(),
missing || !include_linked,
"Include Linked Files retains available source bytes"
);
if pdf_compatible {
let (images, paths) = pdf_contents(&bytes);
if missing {
assert!(images > 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", &params).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, &params).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("<image"), missing, "{format}/{command}: {text}");
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();
let opened = restored
.execute("document.open", &json!({"name": format!("editable.{format}"), "dataBase64": vectorcraft_format::base64_encode(&bytes)}))
.unwrap();
assert_eq!(opened["restored"], true, "{opened}");
eprintln!("SVG_EDITING_RESTORED_READY missing={missing} format={format} command={command}");
assert_eq!(placed(&restored, id), original, "{format}/{command}: link or transform lost");
assert_eq!(restored.execute("links.check", &json!({})).unwrap()["missing"], usize::from(missing));
assert_eq!(s.execute("document.json", &json!({})).unwrap(), before, "source unchanged");
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]));
assert_eq!(placed(&restored, id).link.path, replacement);
}
}
let out = s.execute("document.serialize", &json!({"format": format, "preserveEditing": false})).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());
assert_eq!(vectorcraft_svg::text_of(&bytes).unwrap().contains("<image"), missing);
let mut reopened = Session::new();
assert_eq!(
reopened
.execute("document.open", &json!({"name": format!("plain.{format}"), "dataBase64": vectorcraft_format::base64_encode(&bytes)}))
.unwrap()["restored"],
false
);
}
// Dot and case aliases must reach the same original/prepared document boundary.
for alias in ["SVG", ".svg", "SVGZ"] {
let enc = cmd::fileio::encode_all(&s.doc().unwrap().doc, alias, &json!({"preserveEditing": true})).unwrap();
let format = cmd::fileio::format(alias).unwrap().id;
let mut restored = Session::new();
restored
.execute(
"document.open",
&json!({"name": format!("alias.{format}"), "dataBase64": vectorcraft_format::base64_encode(&enc.files[0].1)}),
)
.unwrap();
assert_eq!(placed(&restored, id), original, "{alias}");
}
}
}

View File

@ -67,7 +67,12 @@ fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {
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