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");
}