Fix #526: the exports and the CLI write .ai as Save As does
file.saveAs and document.save wrote a PDF-compatible .ai, but document.export (and with it the CLI's run --export x.ai and convert in.svg out.ai, and MCP's export) refused it as a format that can only be opened, and --help didn't list it as writable. The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF of every artboard with the PDF options, carrying the native document, blank pages with pdfCompatible: false) serve Save As and the exports alike. The .ai format is writable, so document.formats, --help and the MCP export tool list it; an export leaves the document's own path alone. Export As and Export for Screens keep leaving .ai to Save As. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
85319c972c
commit
1e2cb082ba
File diff suppressed because one or more lines are too long
|
|
@ -417,6 +417,12 @@ 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),
|
||||
|
|
|
|||
|
|
@ -465,8 +465,8 @@ pub const FORMATS: &[Format] = &[
|
|||
extensions: &["ai"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
// Saved (Save As), not exported.
|
||||
write: false,
|
||||
// Save As writes it; exports write the same file.
|
||||
write: true,
|
||||
raster: false,
|
||||
options: native::AI_OPTIONS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -305,6 +305,35 @@ 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; 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"))]);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
||||
}
|
||||
|
||||
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
|
||||
/// document's own). Bad options fail here, before anything is encoded.
|
||||
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
|
||||
|
|
@ -339,16 +368,8 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
|
|||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
|
||||
// A PDF of every artboard with the PDF options, always carrying the native document.
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
// Use Compression compresses the PDF's content.
|
||||
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));
|
||||
}
|
||||
}
|
||||
if plan.format.id == "ai" {
|
||||
ai_params(&mut params);
|
||||
}
|
||||
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
|
||||
}
|
||||
|
|
@ -372,25 +393,14 @@ impl SaveJob {
|
|||
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
|
||||
let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
|
||||
enc.warnings.splice(0..0, notes);
|
||||
enc.warnings.splice(0..0, fidelity_warning(self.plan.format));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if f.id == "ai" {
|
||||
// The pages (blank without PDF content), carrying the native document.
|
||||
let pages = doc.without_edit_modes();
|
||||
let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
|
||||
let native = || super::native::ai_native(cmd, f, doc, &self.params);
|
||||
let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
|
||||
Encoded { warnings, ..Encoded::one(bytes) }
|
||||
} else {
|
||||
encode_all(doc, f.id, &self.params)?
|
||||
};
|
||||
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ impl Row {
|
|||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
|
||||
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ fn export_extensions_name_every_writable_format() {
|
|||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
|
|
@ -206,6 +207,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
|||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,27 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() {
|
|||
assert_eq!(s.doc().unwrap().path, None);
|
||||
}
|
||||
|
||||
/// #526: the exports (`document.export`, the CLI's `--export` and `convert`) write `.ai` as Save
|
||||
/// As does, leaving the document's own path alone.
|
||||
#[test]
|
||||
fn exports_write_ai_as_save_as_does() {
|
||||
let mut s = rich();
|
||||
let before = comparable(&s.doc().unwrap().doc);
|
||||
let path = tmp("exported.ai");
|
||||
let r = s.execute("document.export", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["format"], "ai", "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
|
||||
assert_eq!(s.doc().unwrap().path, None, "an export doesn't give the document a path");
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}");
|
||||
assert_eq!(comparable(&s.doc().unwrap().doc), before);
|
||||
// Its options as Save As takes them; no path gives the bytes.
|
||||
let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("without PDF content"))), "{r}");
|
||||
assert!(b64(&r).starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ait_templates_open_their_document_untitled() {
|
||||
let mut s = rich();
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
|||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ use crate::{VectorcraftApp, io, widgets};
|
|||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The formats Export As writes (the native format is Save's).
|
||||
static FORMATS: LazyLock<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
|
||||
/// The formats Export As writes (the native format and `.ai` are Save As's).
|
||||
static FORMATS: LazyLock<Vec<&'static Format>> =
|
||||
LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && !matches!(f.id, "vectorcraft" | "ai")).collect());
|
||||
/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out.
|
||||
const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
|
||||
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect());
|
||||
|
|
|
|||
|
|
@ -423,6 +423,8 @@ document exactly (`restored: true`); when another app changed the pages, or the
|
|||
imported instead and the first warning says why. Choosing a standard turns it off (PDF/A refuses it). Save to a `.ai`
|
||||
path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the
|
||||
document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled.
|
||||
`document.export` / `serialize` to `.ai` (and the CLI's `run --export x.ai` and `convert in.svg out.ai`) write the same file
|
||||
without giving the document a path.
|
||||
|
||||
Drive a tool like a mouse:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue