From 01798d86ab2a04daa549767684b336a413317481 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 05:34:19 -0800 Subject: [PATCH] Native format: tests for exact saves, rounding halves, closed and tiny paths Without `precision` 500 coordinates of every size reopen bit for bit and a second save writes the same bytes. Halves round away from zero, a closed path keeps its closing anchor on its first one, a shape smaller than one step collapses onto one point, coordinates at the edge of the canvas round to the asked places, and saving a rounded file again at the same precision writes the same bytes. docs/mcp.md states these rules. --- crates/format/tests/it/save_options.rs | 90 +++++++++++++++++++++++++- docs/mcp.md | 5 +- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/format/tests/it/save_options.rs b/crates/format/tests/it/save_options.rs index 5cbf45e9c..3cd163ed1 100644 --- a/crates/format/tests/it/save_options.rs +++ b/crates/format/tests/it/save_options.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::links::LinkInfo; -use vectorcraft_doc::{Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, Node, NodeKind, OpacityMask, Symbol}; +use vectorcraft_doc::{ + Appearance, BlendSpec, Document, EnvelopeKind, ImageBlob, ImageObject, LiveShape, MAX_COORD, Node, NodeKind, OpacityMask, Symbol, +}; use vectorcraft_format::{ ICC_MIME, MAX_PRECISION, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with, }; @@ -324,3 +326,89 @@ fn without_precision_coordinates_are_written_exactly() { assert_eq!(at(200), at(MAX_PRECISION)); assert!(at(0).len() < at(MAX_PRECISION).len()); } + +/// A document holding one path through `points`. +fn doc_with_path(points: &[Point], closed: bool) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let path = PathData::single(SubPath::polyline(points, closed)); + d.insert(Some(layer), 0, Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))).unwrap(); + d +} + +/// The first subpath of the first object of `d` saved at `precision` and opened again. +fn saved_subpath(d: &Document, precision: Option) -> SubPath { + let back = load(&save_with(d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap(); + let NodeKind::Path { path, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("not a path") }; + path.subpaths[0].clone() +} + +#[test] +fn without_precision_every_coordinate_reopens_bit_for_bit() { + // 500 anchors at coordinates of every size and sign (a fixed pseudo-random sequence). + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + let unit = (seed >> 11) as f64 / (1u64 << 53) as f64 - 0.5; + unit * 10f64.powi((seed % 13) as i32 - 6) + }; + let mut points: Vec = (0..500).map(|_| Point::new(next(), next())).collect(); + points.extend([Point::new(MAX_COORD, -1e-300), Point::new(f64::MIN_POSITIVE, -3_999_999.123_456_789)]); + let d = doc_with_path(&points, false); + let bytes = save_with(&d, &SaveOptions::default()).unwrap(); + let got: Vec = saved_subpath(&d, None).anchors.iter().map(|a| a.p).collect(); + let bits = |ps: &[Point]| ps.iter().map(|p| (p.x.to_bits(), p.y.to_bits())).collect::>(); + assert_eq!(bits(&got), bits(&points)); + // Saving the reopened file writes the same bytes. + assert_eq!(save_with(&load(&bytes).unwrap(), &SaveOptions::default()).unwrap(), bytes); +} + +#[test] +fn precision_rounds_halves_away_from_zero() { + // These halves are exact in binary, so the scaled value is exactly .5. + let d = doc_with_path(&[Point::new(0.125, -0.125), Point::new(0.375, -0.375), Point::new(1.5, -2.5)], false); + let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::>(); + assert_eq!(at(2), [Point::new(0.13, -0.13), Point::new(0.38, -0.38), Point::new(1.5, -2.5)]); + assert_eq!(at(0), [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(2.0, -3.0)]); + // `1.005` is stored as slightly less than the decimal, so it goes down. + let d = doc_with_path(&[Point::new(1.005, -1.005)], false); + assert_eq!(saved_subpath(&d, Some(2)).anchors[0].p, Point::new(1.0, -1.0)); +} + +#[test] +fn a_rounded_closed_path_stays_closed_on_its_first_anchor() { + let first = Point::new(10.123456, 20.987654); + let d = doc_with_path(&[first, Point::new(50.55555, 20.4444), Point::new(30.01, 60.996), first], true); + let sp = saved_subpath(&d, Some(2)); + assert!(sp.closed); + assert_eq!(sp.anchors.len(), 4); + assert_eq!(sp.anchors[0].p, Point::new(10.12, 20.99)); + assert_eq!(sp.anchors[3].p, sp.anchors[0].p); +} + +#[test] +fn a_shape_smaller_than_the_precision_keeps_its_anchors_on_one_point() { + let d = doc_with_path(&[Point::new(5.0001, 5.0001), Point::new(5.0004, 5.0001), Point::new(5.0002, 5.0004)], true); + let sp = saved_subpath(&d, Some(3)); + assert!(sp.closed); + assert_eq!(sp.anchors.iter().map(|a| a.p).collect::>(), [Point::new(5.0, 5.0); 3]); +} + +#[test] +fn precision_rounds_coordinates_at_the_edge_of_the_canvas() { + let points = [Point::new(3_999_999.123_456_789, -3_999_999.987_654_321), Point::new(MAX_COORD, -MAX_COORD)]; + let d = doc_with_path(&points, false); + let at = |p| saved_subpath(&d, Some(p)).anchors.iter().map(|a| a.p).collect::>(); + assert_eq!(at(3), [Point::new(3_999_999.123, -3_999_999.988), points[1]]); + for (got, want) in at(MAX_PRECISION).iter().zip(&points) { + assert!((got.x - want.x).abs() <= 1e-9 && (got.y - want.y).abs() <= 1e-9, "{got:?} vs {want:?}"); + } +} + +#[test] +fn saving_a_rounded_file_at_the_same_precision_again_writes_the_same_bytes() { + let o = SaveOptions { precision: Some(2), ..SaveOptions::default() }; + let once = save_with(&precise_doc(), &o).unwrap(); + assert_eq!(save_with(&load(&once).unwrap(), &o).unwrap(), once); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5754f17a7..7108d0b5c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -639,7 +639,10 @@ their content). `version: 2` or `1` writes a file older VectorCraft versions ope compressed), and `preview: true` embeds a PNG of the first artboard, at most 256 px on its longer side. `precision: 4` (0 to 9) rounds the anchors and handles of paths to that many decimal places (in the layers, symbols, graph designs, patterns, opacity masks and brushes); colors, transforms, opacity and type are written exactly. -The embedded preview and the PDF that `pdfCompatible: true` adds show the rounded paths. Without `precision` a save writes the coordinates exactly, and Save does not remember it. A `.ai` file ignores +Each coordinate goes to the nearest step of its stored binary value, and a value exactly halfway rounds away from +zero (`0.125` at 2 places is `0.13`); `1.005` is stored as slightly less than that and is written as `1.0`. Each anchor is rounded on +its own, so a closed path stays closed and a shape smaller than one step keeps its anchors on one point. Opening the +file and saving it again does not bring back the dropped digits. The embedded preview and the PDF that `pdfCompatible: true` adds show the rounded paths. Without `precision` a save writes the coordinates exactly, and Save does not remember it. A `.ai` file ignores `precision`. A save writes a temporary file and renames it over the old one, so a failed save never damages the file; only the images the document uses are written, and document keys written by a newer version are kept.