.ai export: a .ai of the art's bounds carries no editing data

document.export and document.serialize to .ai with useArtboards: false
wrote the one page of the visible art, but embedded the one-artboard
stand-in as the native document and as the editing copy for other apps.
Reopened, the file replaced the document's artboards with one artboard
around the art.

- The .ai of the art's bounds now leaves out both editing copies and
  returns the warning "Preserve editing was left out: an .ai file of the
  art's bounds would replace the document's artboards when reopened",
  as the PDF of the art's bounds does.
- useArtboards: false with pdfCompatible: false is refused, with or
  without editingData, since the file would have blank pages and no
  editing copy.
- Save, Save As, Save a Copy and exports of the artboards keep both
  copies. Legacy version 7 .ai files are written as before.

Docs: docs/mcp.md, docs/target-app-parity.md, document.export params.
This commit is contained in:
dotCooCoo
2026-11-13 07:28:12 -08:00
parent 77970c6924
commit 393078b4f2
6 changed files with 105 additions and 15 deletions
File diff suppressed because one or more lines are too long
+7
View File
@@ -629,6 +629,13 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
{
return Ok(Encoded::one_with_notes(bytes, notes));
}
// So does a `.ai` file of the art's bounds (a legacy version 7 `.ai` file has none to leave out).
if use_artboards == Some(false)
&& f.id == "ai"
&& let Some(enc) = super::save::encode_ai_art_page(C, f, doc, p)?
{
return Ok(enc);
}
if use_artboards == Some(false)
&& let Some((art, q)) = art_page(C, doc, p)?
{
+32 -1
View File
@@ -685,6 +685,31 @@ struct AiEditingData {
editing_data: Option<bool>,
}
/// Why a `.ai` file of the art's bounds (`useArtboards: false`) carries no editing data.
pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str =
"Preserve editing was left out: an .ai file of the art's bounds would replace the document's artboards when reopened";
/// The error for a `.ai` file of the art's bounds without PDF content.
pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false";
/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of
/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor
/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy
/// version 7 `.ai` file.
pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Option<Encoded>> {
if super::encode::use_artboards(cmd, p)? != Some(false) || super::legacy_ai::wanted(cmd, f, p)? {
return Ok(None);
}
if !bool_or(p, "pdfCompatible", true) {
return Err(bad(cmd, ART_PAGE_NEEDS_PDF));
}
let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) };
let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?;
enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string());
Ok(Some(enc))
}
/// The shared Save and Export encoder for `.ai` files (and `.ait` templates, the same file):
/// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7
/// `.ai` files (`aiVersion: "7"`).
@@ -692,9 +717,15 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
if super::legacy_ai::wanted(cmd, f, p)? {
return super::legacy_ai::encode(cmd, f, doc, p);
}
encode_ai_pages(cmd, f, doc, p, true)
}
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
/// the editing copy for other apps when `editing` is true, and neither otherwise.
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> 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));
o.insert("preserveEditing".into(), json!(editing));
// 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));
+59 -12
View File
@@ -402,28 +402,75 @@ fn ai_export_validates_options_even_when_editing_data_is_omitted() {
}
}
#[test]
fn ai_export_without_artboards_keeps_art_outside_the_original_pages() {
/// Two artboards, a visible 30 × 20 pt rectangle outside them at (180, 100) and a hidden one.
fn art_outside_the_artboards() -> Session {
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();
s
}
/// Whether the `.ai` file `bytes` carries the editing copy of its art for other apps.
fn carries_private_data(bytes: &[u8]) -> bool {
let opts = LoadOptions { native: false, ..Default::default() };
load::load_with("a.ai", bytes, &opts).unwrap().editing_data
}
#[test]
fn an_ai_of_the_arts_bounds_carries_no_editing_data() {
let mut s = art_outside_the_artboards();
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);
let left_out = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w == super::save::AI_EDITING_NEEDS_THE_ARTBOARDS);
let path = tmp("art-bounds.ai");
for extra in [json!({}), json!({"range": "2"}), json!({"editingData": false}), json!({"path": path})] {
let mut params = json!({"format": "ai", "useArtboards": false});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let r = s.execute("document.export", &params).unwrap();
let bytes = if params.get("path").is_some() { std::fs::read(&path).unwrap() } else { b64(&r) };
// One page, the visible art's bounds, whatever range names.
let pages = vectorcraft_pdf::import(&bytes).unwrap();
assert_eq!(pages.artboards.len(), 1, "{params}");
assert_eq!((pages.artboards[0].rect.width(), pages.artboards[0].rect.height()), (30.0, 20.0), "{params}");
assert!(vectorcraft_pdf::editing(&bytes).is_none(), "{params}");
assert!(!carries_private_data(&bytes), "{params}");
assert!(left_out(&r), "{params}: {r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
// The artboards keep both editing copies, without the warning.
for params in [json!({"format": "ai"}), json!({"format": "ai", "useArtboards": true})] {
let r = s.execute("document.export", &params).unwrap();
let bytes = b64(&r);
let editing = vectorcraft_pdf::editing(&bytes).expect("editing data");
assert_eq!(vectorcraft_format::load(&editing.data).unwrap().artboards.len(), 2, "{params}");
assert!(carries_private_data(&bytes), "{params}");
assert!(!left_out(&r), "{params}: {r}");
}
assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err());
}
#[test]
fn an_ai_of_the_arts_bounds_without_pdf_is_refused() {
let mut s = art_outside_the_artboards();
let before = comparable(&s.doc().unwrap().doc);
for extra in [json!({}), json!({"editingData": false}), json!({"editingData": true})] {
for command in ["document.export", "document.serialize"] {
let mut params = json!({"format": "ai", "useArtboards": false, "pdfCompatible": false});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let e = s.execute(command, &params).unwrap_err().to_string();
assert!(e.contains(super::save::ART_PAGE_NEEDS_PDF), "{command} {params}: {e}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
}
// A document with no visible art is still refused for that.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false})).unwrap_err().to_string();
assert!(e.contains("no visible art"), "{e}");
}
/// Color Halftone's Max Radius is in pixels of the raster effects resolution since document
/// version 3 (#3260). A PDF saved with Preserve Editing carries the native document, so one from
/// before that opens with the radius converted, drawing the same dots; one saved since opens as
+5 -1
View File
@@ -969,7 +969,11 @@ SVG writes one file per artboard of a `range`); `useArtboards: false` exports th
its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same
way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and
`range`. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it
is in VectorCraft Default), the export comes back with a warning that says so.
is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with
`useArtboards: false` writes that one page too, without the native document and without the editing copy for other
apps (`editingData`), and always comes back with the warning `Preserve editing was left out: an .ai file of the art's
bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever
`editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before.
Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no
Artboards section and the page is the bounds of the visible art.
+1
View File
@@ -1577,6 +1577,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`).
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard.
Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`).
A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`).
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`).
PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`).