Export: warnings name an option the chosen format doesn't read

`document.export`, `document.serialize` and `document.exportSelection`
accept a key that only another format takes. `{"format": "pdf",
"quality": 80}` wrote the PDF and gave no warning about `quality`.

The reply's `warnings` now name each such key, in the wording of a
save's note: "`quality` was not used: PDF files don't read it".

- The format's own options, the commands' own keys, null values and an
  exporter plug-in's format get no note.
- The note names the kind of file written. A legacy `.ai` file
  (`aiVersion: "7"`) reads `outlineText`, `hiddenObjects`,
  `transparentImages`, the artboard choice and `useArtboards`, and
  beside `aiVersion` no other option. The other `.ai` files don't read
  those three options or `preserveEditing`, which is always on for
  them.
- An export doesn't read `separateArtboards`, or `range` for a
  `vectorcraft` or `template` file. A save reads them to write each
  artboard to a file of its own.
- In the app, an export that automation asked for (`app.export` with a
  path, `file.exportAs` run as a command) gives the same notes in its
  reply and in the status bar. A Background Export replies before the
  file is written, and that reply has the notes. An export that a
  person starts from the File menu gets none.
- Export As gives a template export no `useArtboards` or `range`: a
  template doesn't read them.
- `vectorcraft-cli convert` sends `outlineText` only with
  `--outline-text`, and `hiddenLayers` only to the formats that read
  it. A conversion then warns only about an option the command line
  gave.

`document.save` gives its notes as before: it does not apply the `.ai`
rules above.

Docs: docs/mcp.md, docs/control-protocol.md,
docs/target-app-parity.md, the params text of the three commands.
This commit is contained in:
dotCooCoo
2026-11-13 06:58:28 -08:00
parent 1dbdbce91f
commit d04a19ac53
17 changed files with 353 additions and 27 deletions
+2 -2
View File
@@ -69,8 +69,8 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{export_crop, isolated};
pub use export::{export_doc, export_source};
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
pub use export::{export_doc, export_source, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
File diff suppressed because one or more lines are too long
+49 -3
View File
@@ -15,8 +15,45 @@ use super::{
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
/// The keys the export commands take for every format, beside the path, the format and the
/// options `document.formats` lists for each: `richBlack` is Appearance of Black's setting for a
/// raster export, and `params` an exporter plug-in's own.
pub(crate) const EXPORT_PARAMS: [&str; 4] = ["includeArtboardFill", "selectedOnly", "richBlack", "params"];
/// A note for each option in `p` that some format takes but the file an export of `cmd` writes in
/// `f` doesn't read (`{format: pdf, quality: 80}`), in the wording of a save's note. An exporter
/// plug-in's format gets none. The notes cover the formats' options only: a key of the command's
/// own ([`EXPORT_PARAMS`]) and an artboard choice that a selection export leaves out get none.
pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
let Some(given) = p.as_object() else { return Vec::new() };
if vectorcraft_plugins::export::by_format(f.id).is_some() {
return Vec::new();
}
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let written = super::save::written_as(cmd, f, p);
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
let reads = |k: &str| match (f.id, k) {
// A legacy `.ai` file is not a PDF: it reads its own options only.
("ai", _) if legacy => super::legacy_ai::reads(k),
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
// on for them whatever is given.
("ai", "preserveEditing") => false,
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
// An export writes one file: only a save writes each artboard to a file of its own, and
// a native file's `range` names those files.
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::reads_option(f, k),
};
given
.iter()
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
.map(|(k, _)| super::save::unused_option_note(k, &written))
.collect()
}
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
let unused = unused_option_notes("document.serialize", f, p);
let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
@@ -27,7 +64,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let mut enc = encode_all(doc, f.id, &q)?;
enc.add_setting_notes(unused);
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -58,6 +96,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", text_param(p, "format", "document.export")?, path)?;
let unused = unused_option_notes("document.export", f, p);
let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded);
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
@@ -68,7 +107,8 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let mut enc = encode_all(doc, f.id, &q)?;
enc.add_setting_notes(unused);
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() }))
}
@@ -315,12 +355,18 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let f = writable(C, text_param(p, "format", C)?, path)?;
let unused = unused_option_notes(C, f, p);
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C, f.id == "txt")?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
let (bytes, mut warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
for note in unused {
if !warnings.contains(&note) {
warnings.push(note);
}
}
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
+12
View File
@@ -65,6 +65,18 @@ pub(super) fn named(f: &Format) -> Format {
Format { label: "legacy .ai (version 7)", ..*f }
}
/// The `.ai` options that only a legacy file reads: a PDF-compatible `.ai` file and a `.ai` file
/// without PDF content don't.
pub(super) const LEGACY_ONLY: [&str; 3] = ["transparentImages", "hiddenObjects", "outlineText"];
/// Does an export of a legacy file read option `key`? It reads the version, the file's own
/// options ([`LEGACY_ONLY`]), the artboards to write, and `useArtboards`, which the export reads
/// before [`encode`] runs. It reads none of the other `.ai` options and none of the PDF options
/// that a PDF-compatible `.ai` file takes.
pub(super) fn reads(key: &str) -> bool {
matches!(key, "aiVersion" | "useArtboards") || LEGACY_ONLY.contains(&key) || super::ARTBOARD_PARAMS.contains(&key)
}
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SeeThrough {
+8 -2
View File
@@ -317,7 +317,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
let notes = top
.filter(|k| !reads_option(format, k))
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
.map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
.map(|k| unused_option_note(&k, &written))
.collect();
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
@@ -467,6 +467,12 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
/// The note a save or an export gives for option `key`, as the request names it, which the file it
/// writes (`written`, see [`written_as`]) doesn't read.
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
format!("`{key}` was not used: {} files don't read it", written.label)
}
/// Formats that carry the whole native document: they lose nothing, and record links relative to
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments.
fn is_native(f: &Format) -> bool {
@@ -636,7 +642,7 @@ fn without_pdf(f: &Format) -> Format {
/// `f` named, in its errors and notes, as the file a save with options `p` writes: a version 7
/// `.ai` as the legacy `.ai`, and a `.ai` or `.ait` with `pdfCompatible: false` as one saved
/// without PDF content.
fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
pub(super) fn written_as(cmd: &str, f: &Format, p: &Value) -> Format {
if f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false) {
super::legacy_ai::named(f)
} else if matches!(f.id, "ai" | "ait") && !bool_or(p, "pdfCompatible", true) {
@@ -141,6 +141,137 @@ fn what_the_file_loses_comes_before_a_note_on_the_options() {
assert_eq!(r["warnings"], json!(w));
}
/// A session with one artboard and a selected rectangle on it.
fn with_art() -> Session {
let mut s = session(60.0, 40.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
s
}
/// The warnings of reply `r` that name an option the file doesn't read.
fn unused(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
}
/// An option that only another format reads is accepted, and the reply's warnings name it in the
/// words of a save's note; `document.export`, `document.serialize` and `document.exportSelection`
/// give the same note.
#[test]
fn an_option_only_another_format_reads_gets_a_note() {
let (mut s, notes) = (with_art(), unused);
for cmd in ["document.export", "document.serialize", "document.exportSelection"] {
let r = s.execute(cmd, &json!({"format": "pdf", "quality": 80, "outlineText": true})).unwrap();
assert_eq!(
notes(&r),
["`outlineText` was not used: PDF files don't read it", "`quality` was not used: PDF files don't read it"],
"{cmd}: {r}"
);
}
// The format's own options, the keys every export takes and null values get none.
for p in [
json!({"format": "jpg", "quality": 80, "scale": 2}),
json!({"format": "ai", "compress": true, "compatibility": "1.7"}),
json!({"format": "pdf", "quality": null, "selectedOnly": false, "includeArtboardFill": false, "useArtboards": true}),
json!({"format": "svg", "outlineText": true, "svg": {"decimals": 2}}),
] {
let r = s.execute("document.export", &p).unwrap();
assert!(notes(&r).is_empty(), "{p}: {r}");
}
let r = s.execute("document.serialize", &json!({"format": "png", "compress": true, "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
assert_eq!(
notes(&r),
[
"`compress` was not used: PNG files don't read it",
"`marks` was not used: PNG files don't read it",
"`svg` was not used: PNG files don't read it"
],
"{r}"
);
// The note names the kind of file written, as a save's does.
let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false, "quality": 80})).unwrap();
assert_eq!(notes(&r), ["`quality` was not used: non-PDF-compatible .ai files don't read it"], "{r}");
let r = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false, "range": "1"})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
let r = s.execute("document.export", &json!({"format": "vectorcraft", "artboard": 0})).unwrap();
assert_eq!(notes(&r), ["`artboard` was not used: VectorCraft files don't read it"], "{r}");
}
/// A legacy `.ai` file reads its own options and the artboards to write, not the other `.ai`
/// options or the PDF options.
#[test]
fn a_legacy_ai_export_notes_the_options_of_the_other_ai_files() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}})).unwrap();
assert_eq!(
notes(&r),
["`compress` was not used: legacy .ai (version 7) files don't read it", "`marks` was not used: legacy .ai (version 7) files don't read it"],
"{r}"
);
let r =
s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "outlineText": true, "hiddenObjects": "omit", "range": "1"})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": "7", "useArtboards": false})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
}
/// The `.ai` files other than the legacy one don't read the legacy file's own options.
#[test]
fn a_pdf_compatible_ai_export_notes_the_legacy_options() {
let (mut s, notes) = (with_art(), unused);
let unread = json!({"format": "ai", "aiVersion": "pdf", "outlineText": true, "hiddenObjects": "omit", "transparentImages": "omit"});
let r = s.execute("document.export", &unread).unwrap();
assert_eq!(
notes(&r),
[
"`hiddenObjects` was not used: PDF-compatible .ai files don't read it",
"`outlineText` was not used: PDF-compatible .ai files don't read it",
"`transparentImages` was not used: PDF-compatible .ai files don't read it"
],
"{r}"
);
}
/// `preserveEditing` is always on for a `.ai` file, so an export that gives it gets a note.
#[test]
fn an_ai_export_notes_preserve_editing() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "preserveEditing": false, "range": "1"})).unwrap();
assert_eq!(notes(&r), ["`preserveEditing` was not used: PDF-compatible .ai files don't read it"], "{r}");
}
/// An export writes one file: only a save writes each artboard to a file of its own, so an export
/// notes `separateArtboards`, and the `range` that names those files for a native file.
#[test]
fn an_export_notes_the_options_that_only_a_save_reads() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("document.export", &json!({"format": "ai", "separateArtboards": true, "range": "1"})).unwrap();
assert_eq!(notes(&r), ["`separateArtboards` was not used: PDF-compatible .ai files don't read it"], "{r}");
for (format, label) in [("vectorcraft", "VectorCraft"), ("template", "VectorCraft Template")] {
let r = s.execute("document.export", &json!({"format": format, "separateArtboards": true, "range": "1", "compress": true})).unwrap();
assert_eq!(
notes(&r),
[format!("`range` was not used: {label} files don't read it"), format!("`separateArtboards` was not used: {label} files don't read it")],
"{r}"
);
}
}
/// The legacy `.ai` options that the notes go by are the ones the `.ai` option table describes as
/// the legacy file's ("with aiVersion 7"), so an option added to the table is not noted as unread
/// on a legacy export by mistake.
#[test]
fn the_legacy_ai_options_are_the_ones_the_option_table_names() {
let described: Vec<&str> = native::AI_OPTIONS.iter().filter(|o| o.description.starts_with("with aiVersion 7")).map(|o| o.name).collect();
assert_eq!(described, legacy_ai::LEGACY_ONLY);
for key in described.iter().chain(&ARTBOARD_PARAMS).chain(&["aiVersion", "useArtboards"]) {
assert!(legacy_ai::reads(key), "{key}");
assert!(native::AI_OPTIONS.iter().any(|o| o.name == *key), "{key} is not a .ai option");
}
for key in ["compress", "pdfCompatible", "separateArtboards", "editingData", "marks"] {
assert!(!legacy_ai::reads(key), "{key}");
}
}
#[test]
fn note_texts_give_the_losses_first_each_kind_in_order() {
use super::encode::{Note, note_texts};
+1 -3
View File
@@ -773,9 +773,7 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(super::fileio::EXPORT_PARAMS);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
NO_FORMAT
}