Native format: document.save can write path coordinates at a fixed precision

Native files store every anchor and handle as a full double, such as
3.4876474632797176, so a file of traced art is larger than it needs
to be.

document.save, file.saveAs, file.saveCopy and file.saveAsTemplate take
precision (0 to 9) for native files. The save rounds the anchors and
handles of path objects, Live Corners' uncut outlines, blend spines,
envelope outlines, Intertwine areas and brush art to that many decimal
places, in the layers, symbols, graph designs, patterns and opacity
masks, and in the art that placed documents become in files for older
versions. Colors, transforms, opacity and type are written exactly.
Without precision the file is written as before, and Save does not
remember the option. A .ai file, saved or exported, ignores precision.
The VectorCraft Options dialog shows no row for it.

A save given precision beside the path for a format that does not read
it (SVG, PDF, .ai and .ait) says so in its warnings, as it does for
compress, version and preview. MCP save_file passes precision on to the
save as it passes the other native options, and its schema, description
and docs/mcp.md list it.
This commit is contained in:
dotCooCoo
2026-10-24 19:26:22 -07:00
parent 8bf090b253
commit 60700e00fc
12 changed files with 445 additions and 26 deletions
+24 -2
View File
@@ -35,6 +35,12 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
},
FormatOption {
name: "precision",
ty: "integer",
default: "null",
description: "round path anchors and handles to this many decimal places, 0 to 9 (a smaller file; colors, transforms and type are kept exactly); null writes them exactly. Only the save that gives it rounds",
},
SEPARATE_ARTBOARDS,
SEPARATE_RANGE,
INCLUDE_LINKED,
@@ -131,6 +137,7 @@ struct NativeOptions {
compress: Option<bool>,
version: Option<u32>,
preview: bool,
precision: Option<Value>,
include_linked: bool,
embed_profiles: Option<bool>,
pdf_compatible: bool,
@@ -151,6 +158,7 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
compress: o.compress.unwrap_or(false),
version: o.version.unwrap_or(vectorcraft_format::VERSION),
include_linked: o.include_linked,
precision: o.precision.map(|v| precision(cmd, &v)).transpose()?,
..SaveOptions::default()
};
if o.embed_profiles.unwrap_or(true) {
@@ -160,6 +168,20 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
}
/// The `precision` option `v`: a whole number of decimal places, 0 to
/// [`vectorcraft_format::MAX_PRECISION`]. Any other value, a string included, gets the range message.
fn precision(cmd: &str, v: &Value) -> Result<u8> {
let (key, min, max) = ("precision", 0u8, vectorcraft_format::MAX_PRECISION);
if let Some(x) = v.as_f64()
&& x.fract() == 0.0
&& (f64::from(min)..=f64::from(max)).contains(&x)
{
return Ok(x as u8);
}
let x = v.as_f64().map_or_else(|| v.to_string(), |x| x.to_string());
Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})")))
}
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let mut out = Vec::new();
@@ -230,8 +252,8 @@ pub(super) fn ai_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Re
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
// 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"));
// and PDF-content flags are still validated as booleans. Its coordinates are written exactly.
o.retain(|k, _| !matches!(k.as_str(), "version" | "preview" | "precision"));
if o.get("pdfCompatible").is_some_and(Value::is_null) {
o.remove("pdfCompatible");
}
+8 -6
View File
@@ -257,7 +257,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
given = svg;
} else {
// So do PDF options (as the Save PDF dialog and document.exportPdf name them) and the
// native ones (compress, version, preview).
// native ones (compress, version, preview, precision).
if let Some(o) = p.as_object() {
given.extend(
o.iter().filter(|(k, v)| !v.is_null() && k.as_str() != "path" && reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())),
@@ -288,7 +288,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
/// The native save options, which a save also takes beside the path.
const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"];
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
/// The first option `p` gives both beside the path and in `options` with different values, as
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
@@ -827,6 +827,8 @@ impl SaveJob {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
// A later save rounds only when it gives `precision` too.
st.save_options.remove("precision");
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
st.converted = false;
st.recovered = false;
@@ -942,7 +944,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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), 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), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), 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; a .ai file's compress, default true, is its 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} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat), 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), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), 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; a .ai file's compress, default true, is its 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)
),
@@ -951,7 +953,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save As…",
["File"],
Some("Cmd+Shift+S"),
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, precision?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::SaveAs, p)
),
@@ -960,7 +962,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save a Copy…",
["File"],
Some("Cmd+Alt+S"),
"{…as document.save} {path?, format?, options?} write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",
"{…as document.save} {path?, format?, options?, precision?} write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::Copy, p)
),
@@ -969,7 +971,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai; aiVersion 7 is an error); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?, precision?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai; aiVersion 7 is an error); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
+12 -2
View File
@@ -703,8 +703,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
}
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't
/// read it is named in the warnings.
/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
/// that doesn't read it is named in the warnings.
#[test]
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let d = tmp_dir("save-options");
@@ -751,6 +751,16 @@ fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}");
// `precision` is read by native files and templates only.
for name in ["p.svg", "p.pdf", "p.ai"] {
let r = s.execute("file.saveCopy", &json!({"path": d.join(name).to_string_lossy(), "precision": 2})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(w.iter().any(|w| w.starts_with("`precision` was not used:")), "{name}: {w:?}");
}
for name in ["p.vectorcraft", "p.vctemplate"] {
let r = s.execute("file.saveCopy", &json!({"path": d.join(name).to_string_lossy(), "precision": 2})).unwrap();
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
}
let _ = std::fs::remove_dir_all(&d);
}
@@ -691,6 +691,47 @@ fn a_placed_documents_resources_stay_its_own() {
assert!(near(centre_colour(&old), BLUE));
}
#[test]
fn saved_for_older_apps_with_a_precision_the_placed_art_is_rounded_too() {
let dir = Folder::new("placed-precision");
let src = dir.file("triangle.vectorcraft");
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let id = d.alloc_id();
let points = [(3.4876474632797176, 1.0), (90.0, 2.123456789), (40.0, 80.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y));
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::path::SubPath::polyline(&points, true));
d.insert(Some(layer), 0, Node::path(id, path, look(BLUE))).unwrap();
write(&src, &vectorcraft_format::save_file(&d));
let mut s = session();
place(&mut s, &src);
// The coordinates of every anchor and handle in a file's layers.
fn coords(v: &Value, out: &mut Vec<f64>) {
match v {
Value::Object(o) => {
for a in o.get("anchors").and_then(Value::as_array).into_iter().flatten() {
out.extend(["p", "in", "out"].iter().filter_map(|k| a[*k].as_array()).flatten().filter_map(Value::as_f64));
}
o.values().for_each(|c| coords(c, out));
}
Value::Array(a) => a.iter().for_each(|c| coords(c, out)),
_ => {}
}
}
let mut saved = |p: Value| {
let file: Value = serde_json::from_slice(&decode(&s.execute("document.serialize", &p).unwrap())).unwrap();
assert!(!file.to_string().contains("placeddocument"), "{file}");
let mut out = vec![];
coords(&file["document"]["layers"], &mut out);
out
};
let at4 = |x: f64| (x * 1e4).round() / 1e4 == x;
let exact = saved(json!({"format": "vectorcraft", "version": 3}));
assert!(exact.len() >= 6 && !exact.iter().all(|x| at4(*x)), "{exact:?}");
let rounded = saved(json!({"format": "vectorcraft", "version": 3, "precision": 4}));
assert_eq!(rounded.len(), exact.len());
assert!(rounded.iter().all(|x| at4(*x)), "{rounded:?}");
}
#[test]
fn break_link_gives_the_editable_copy_placing_without_link_gives() {
let dir = Folder::new("placed-break");
+70
View File
@@ -353,3 +353,73 @@ fn format_options_list_the_save_options() {
let v = s.execute("file.formatOptions", &json!({})).unwrap();
assert_eq!(v["options"]["includeLinked"]["value"], true);
}
/// The anchors of the first object in the native file at `path`.
fn saved_anchors(path: &str) -> Vec<vectorcraft_geom::Point> {
let d = vectorcraft_format::load(&std::fs::read(path).unwrap()).unwrap();
let NodeKind::Path { path, .. } = &d.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()
}
#[test]
fn precision_rounds_the_paths_of_that_save_only() {
let dir = Folder::new("precision");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 1000, "height": 1000})).unwrap();
// 1,000 anchors at coordinates with many decimal places (a fixed pseudo-random sequence).
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(seed >> 11) as f64 / (1u64 << 53) as f64 * 1000.0
};
let anchors: Vec<Value> = (0..1000).map(|_| json!({"x": next(), "y": next()})).collect();
s.execute("path.create", &json!({"anchors": anchors})).unwrap();
let (exact, rounded) = (dir.file("exact.vectorcraft"), dir.file("rounded.vectorcraft"));
s.execute("document.save", &json!({"path": exact, "compress": false})).unwrap();
s.execute("document.save", &json!({"path": rounded, "compress": false, "precision": 3})).unwrap();
let (e, r) = (std::fs::metadata(&exact).unwrap().len(), std::fs::metadata(&rounded).unwrap().len());
assert!(r < e, "{r} bytes rounded, {e} exact");
let (want, got) = (saved_anchors(&exact), saved_anchors(&rounded));
assert_eq!(got.len(), 1000);
for (w, g) in want.iter().zip(&got) {
assert!((w.x - g.x).abs() <= 0.0005 && (w.y - g.y).abs() <= 0.0005, "{w:?} saved as {g:?}");
assert_eq!(((g.x * 1000.0).round() / 1000.0, (g.y * 1000.0).round() / 1000.0), (g.x, g.y));
}
// The document keeps its exact anchors, and Save doesn't remember the precision.
let v = s.execute("file.formatOptions", &json!({})).unwrap();
assert!(v["options"]["precision"]["value"].is_null(), "{}", v["options"]["precision"]);
s.execute("document.save", &json!({})).unwrap();
assert_eq!(saved_anchors(&rounded), want);
// A precision that isn't a whole number from 0 to 9 is refused, by the command that was given it.
for (wrong, got) in [(json!(10), "10"), (json!(2.5), "2.5"), (json!(-1), "-1"), (json!("4"), "\"4\""), (json!(true), "true")] {
for cmd in ["file.saveCopy", "document.save"] {
let e = s.execute(cmd, &json!({"precision": wrong})).unwrap_err().to_string();
assert!(e.contains(&format!("`{cmd}`")) && !e.contains("document.export"), "{cmd} {wrong}: {e}");
assert!(e.contains(&format!("`precision` must be a whole number between 0 and 9 (got {got})")), "{cmd} {wrong}: {e}");
}
}
// A type error in another native option names that command too.
let e = s.execute("document.save", &json!({"version": "4"})).unwrap_err().to_string();
assert!(e.contains("`document.save`") && !e.contains("document.export"), "{e}");
}
#[test]
fn a_ai_file_ignores_precision_as_save_does() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("path.create", &json!({"anchors": [{"x": 10.123456789, "y": 20.987654321}, {"x": 60.5, "y": 70.25}]})).unwrap();
let want: Vec<_> = {
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect()
};
for cmd in ["document.export", "file.saveCopy"] {
for precision in [json!(3), json!(10)] {
let r = s.execute(cmd, &json!({"format": "ai", "precision": precision})).unwrap();
let mut t = Session::new();
t.execute("document.open", &json!({"name": "x.ai", "dataBase64": r["dataBase64"]})).unwrap();
let NodeKind::Path { path, .. } = &t.doc().unwrap().doc.layers[0].children().unwrap()[0].kind else { panic!("not a path") };
let got: Vec<_> = path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)).collect();
assert_eq!(got, want, "{cmd} {precision}");
}
}
}