Files
dotCooCoo 63dfcba8ba Envelopes: a top object's sides share the content by control polygon length
A top-object envelope splits its outline into four sides at four corners
and maps the content onto the Coons patch of those sides. The sides
shared out the content by length, and the corners were the anchors or
curve points nearest the bounding box corners.

Each piece of a side between anchors and corners now takes a share of the
content's width or height in proportion to the length of its control
polygon, spread along the piece by its curve parameter. A straight
segment spreads it evenly by length. A rounded rectangle stays close to
the identity, and the content follows the curves of a side whose segments
differ in size.

Each corner is now the nearest anchor to its bounding box corner among
the sharp anchors that point out of the outline and lie within 47.5% of
the box's width and height from that corner. A handle shorter than
0.01 pt, or tangent unit vectors that differ by 0.01 or more in x or y,
makes an anchor sharp, and the first one along the outline wins a tie.
Without such an anchor, the corner is the outline's nearest point to the
box corner, moved onto the end anchor of its segment within 0.5% of the
segment's parameter. Envelopes opened from .ai files keep the corners the
file saves.

These are VectorCraft's own rules. Existing top-object envelopes redraw by
them. Gradient meshes made from a path keep their sides measured by length.

- crates/doc/src/live/mesh.rs: Coons::from_top_object and the corner
  search, used for top-object envelopes.
- EnvelopeKind::TopObject gains an optional `corners` field (anchor
  indices); the .ai importer fills it from the mesh's corners.
- Params text of object.envelope.makeWithTopObject, docs/mcp.md
  (Envelopes) and a progress line in docs/target-app-parity.md.
2026-11-09 00:31:06 +07:00

415 lines
20 KiB
Rust

//! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the
//! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't
//! need them).
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, 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,
};
use vectorcraft_geom::path::{Anchor, PathData, SubPath};
use vectorcraft_geom::{Affine, Point};
use vectorcraft_testkit::fixtures::DocBuilder;
fn json_of(bytes: &[u8]) -> Value {
serde_json::from_slice(bytes).unwrap()
}
/// A document showing image `k` linked to a file; the blob holds the file's bytes and a preview.
fn linked_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let id = d.alloc_id();
let im = ImageObject {
key: "k".into(),
width: 2,
height: 2,
xf: Affine::IDENTITY,
link: Some(LinkInfo::new("/art/photo.png")),
placement: Default::default(),
pixels: None,
};
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]);
blob.proxy = Some(Arc::new(vec![9, 9]));
d.images.insert("k".into(), blob);
d
}
#[test]
fn include_linked_files_keeps_the_files_bytes() {
let d = linked_doc();
let plain = save_with(&d, &SaveOptions::default()).unwrap();
let image = &json_of(&plain)["images"]["k"];
assert_eq!(image["proxy"], true, "only the preview by default");
let full = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap();
let image = &json_of(&full)["images"]["k"];
assert!(image.get("proxy").is_none());
let back = load(&full).unwrap();
assert_eq!(back.images["k"].bytes.as_slice(), &[1, 2, 3, 4, 5, 6]);
assert!(!back.images["k"].is_proxy());
// Only the preview loaded (the file was never found): the preview is all there is to write.
let mut missing = linked_doc();
let preview = Arc::new(vec![9, 9]);
missing.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) });
let out = save_with(&missing, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap();
assert_eq!(json_of(&out)["images"]["k"]["proxy"], true);
}
#[test]
fn embedded_profiles_and_pdf_round_trip() {
let mut d = Document::new(100.0, 100.0);
d.color_profiles.rgb = Some("Studio RGB".into());
let o = SaveOptions {
profiles: [("Studio RGB".to_string(), vec![0, 1, 2, 3, 250])].into(),
pdf: Some(b"%PDF-1.7 fake".to_vec()),
compress: true,
..SaveOptions::default()
};
let bytes = save_with(&d, &o).unwrap();
let f = load_file(&bytes).unwrap();
assert_eq!(f.profiles["Studio RGB"], vec![0, 1, 2, 3, 250]);
assert_eq!(f.doc.color_profiles.rgb.as_deref(), Some("Studio RGB"));
assert_eq!(pdf_content(&bytes).unwrap(), b"%PDF-1.7 fake");
// The plain file says what they are; a file without them has neither member.
let plain = save_with(&d, &SaveOptions { compress: false, ..o }).unwrap();
let v = json_of(&plain);
assert_eq!((v["profiles"]["Studio RGB"]["mime"].as_str(), v["pdf"]["mime"].as_str()), (Some(ICC_MIME), Some("application/pdf")));
let none = save_with(&d, &SaveOptions::default()).unwrap();
assert!(json_of(&none).get("profiles").is_none() && pdf_content(&none).is_none());
assert!(load_file(&none).unwrap().profiles.is_empty());
// Damaged profile data is left out rather than failing the open.
let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1);
assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty());
}
/// A document of `paths` open paths with `anchors` anchors each.
fn paths_doc(paths: usize, anchors: usize) -> Document {
let mut b = DocBuilder::new(1000.0, 1000.0);
for i in 0..paths {
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, i as f64 + (j % 2) as f64)).collect();
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
b.add(node);
}
b.doc
}
fn is_pretty(bytes: &[u8]) -> bool {
bytes.windows(3).any(|w| w == b"\n ")
}
#[test]
fn small_documents_are_saved_pretty() {
let d = paths_doc(10, 10);
assert!(SaveOptions::for_doc(&d).pretty);
assert!(is_pretty(&save_file(&d)));
}
#[test]
fn many_objects_are_saved_compact() {
let d = paths_doc(PRETTY_MAX_OBJECTS + 1, 2);
assert!(!SaveOptions::for_doc(&d).pretty);
}
/// Anchors per path that make a document of `paths` paths about `bytes` of compact JSON.
fn anchors_for(paths: usize, bytes: usize) -> usize {
let per_anchor = serde_json::to_vec(&paths_doc(1, 1000)).unwrap().len() / 1000;
bytes / per_anchor / paths
}
#[test]
fn few_objects_with_many_anchors_are_saved_compact() {
let d = paths_doc(3, anchors_for(3, 2 * PRETTY_MAX_BYTES));
assert!(!SaveOptions::for_doc(&d).pretty, "3 objects, but about {} bytes", 2 * PRETTY_MAX_BYTES);
let bytes = save_file(&d);
assert!(!is_pretty(&bytes));
assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count());
}
/// Anchors per path that make a document of `paths` paths about `bytes` of pretty-printed JSON.
fn pretty_anchors_for(paths: usize, bytes: usize) -> usize {
let per_anchor = serde_json::to_vec_pretty(&paths_doc(1, 1000)).unwrap().len() / 1000;
bytes / per_anchor / paths
}
#[test]
fn documents_under_the_size_limit_stay_pretty() {
let d = paths_doc(2, pretty_anchors_for(2, PRETTY_MAX_BYTES / 2));
assert!(SaveOptions::for_doc(&d).pretty);
assert!(is_pretty(&save_file(&d)));
}
#[test]
fn the_limit_is_on_the_pretty_printed_size() {
// One traced-sized path: well under the limit as compact JSON, over it pretty-printed.
let d = paths_doc(1, 20_000);
let compact = serde_json::to_vec(&d).unwrap().len();
let pretty = serde_json::to_vec_pretty(&d).unwrap().len();
assert!(compact < PRETTY_MAX_BYTES && pretty > PRETTY_MAX_BYTES, "compact {compact} bytes, pretty {pretty} bytes");
assert!(!SaveOptions::for_doc(&d).pretty);
let bytes = save_file(&d);
assert!(!is_pretty(&bytes));
assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count());
}
#[test]
fn the_files_indentation_counts_toward_the_limit() {
// The pretty JSON's size and line breaks; the file indents every line after the first by two spaces more.
let measure = |anchors: usize| {
let json = serde_json::to_vec_pretty(&paths_doc(1, anchors)).unwrap();
(json.len(), json.iter().filter(|&&b| b == b'\n').count())
};
// The most anchors for which `fits` holds (it holds for 1 and not past twice the estimate).
let most = |fits: &dyn Fn((usize, usize)) -> bool| {
let (mut lo, mut hi) = (1, 2 * pretty_anchors_for(1, PRETTY_MAX_BYTES));
while lo < hi {
let mid = (lo + hi).div_ceil(2);
if fits(measure(mid)) { lo = mid } else { hi = mid - 1 }
}
lo
};
// Without the file's indentation this many anchors fit the limit, with it they do not.
let bare = most(&|(len, _)| len <= PRETTY_MAX_BYTES);
let (len, newlines) = measure(bare);
assert!(len + 2 * newlines > PRETTY_MAX_BYTES, "{len} bytes, {newlines} line breaks");
assert!(!SaveOptions::for_doc(&paths_doc(1, bare)).pretty);
// Indented, this many fit.
let indented = most(&|(len, newlines)| len + 2 * newlines <= PRETTY_MAX_BYTES);
assert!(indented < bare);
assert!(SaveOptions::for_doc(&paths_doc(1, indented)).pretty);
assert!(!SaveOptions::for_doc(&paths_doc(1, indented + 1)).pretty);
}
#[test]
fn the_limit_is_on_the_version_written() {
// Version 1 writes every anchor with both handles as `{x, y}` maps: this document fits in the
// current version and not in version 1.
let d = paths_doc(1, pretty_anchors_for(1, PRETTY_MAX_BYTES / 2));
let v1 = SaveOptions { version: 1, ..SaveOptions::default() };
assert!(SaveOptions::default().pretty_fits(&d));
assert!(!v1.pretty_fits(&d));
let pretty = save_with(&d, &SaveOptions { pretty: true, ..v1.clone() }).unwrap();
assert!(pretty.len() > PRETTY_MAX_BYTES, "{} bytes", pretty.len());
}
#[test]
fn art_outside_the_layers_counts_too() {
// Generated art kept in a symbol: one instance-sized document, a symbol full of anchors.
let mut d = paths_doc(1, 4);
let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES));
let path = Arc::clone(&art.layers[0]);
art.layers.clear();
d.symbols.push(Symbol { dynamic: false, name: "Leaves".into(), art: path });
assert!(!SaveOptions::for_doc(&d).pretty);
let bytes = save_file(&d);
assert!(!is_pretty(&bytes));
assert_eq!(load(&bytes).unwrap().symbols.len(), 1);
}
/// One path at `x, y`, its out handle at `x + 6.1234567, y - 0.00001` (`-0.0` at 4 decimals when
/// `y` is 0), filled with a color whose components have many digits.
fn precise_path(d: &mut Document, x: f64, y: f64) -> Node {
let p = Point::new(x, y);
let a = Anchor { p, h_in: p, h_out: Point::new(x + 6.1234567, y - 0.00001), kind: Default::default() };
let sp = SubPath::new(vec![a, Anchor::corner(Point::new(x + 20.0, y))], false);
let fill = Paint::solid(Color::rgb(0.02745098, 0.33333334, 0.9019608));
Node::path(d.alloc_id(), PathData::single(sp), Appearance::basic(fill, Paint::None, 0.0))
}
/// A document with a precise path in the layer, in an opacity mask, in a symbol (placed with a
/// precise transform) and in a brush kept as foreign data.
fn precise_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut n = precise_path(&mut d, 3.4876474632797176, 3.499999999999999);
n.extras_mut().mask = Some(Box::new(OpacityMask::new(precise_path(&mut d, 7.77777777, 1.0), false)));
d.insert(Some(layer), 0, n).unwrap();
let art = precise_path(&mut d, 1.23456789, 0.0);
d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
let xf = Affine::new([1.0, 0.0, 0.0, 1.0, 0.123456789, 0.0]);
let id = d.alloc_id();
d.insert(Some(layer), 1, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap();
let brush = serde_json::to_value(precise_path(&mut d, 5.55555555, 4.0)).unwrap();
d.unknown.insert("brushes".into(), serde_json::json!([{"name": "B", "type": "art", "art": brush}]));
d
}
fn first_anchor(n: &Node) -> Anchor {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {n:?}") };
path.subpaths[0].anchors[0]
}
#[test]
fn precision_rounds_path_anchors_and_handles_only() {
let d = precise_doc();
let bytes = save_with(&d, &SaveOptions { precision: Some(4), ..SaveOptions::default() }).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(!text.contains("-0.0"), "a handle rounded to zero is written as 0.0");
let back = load(&bytes).unwrap();
let path = &back.layers[0].children().unwrap()[0];
let a = first_anchor(path);
assert_eq!((a.p, a.h_in, a.h_out), (Point::new(3.4876, 3.5), Point::new(3.4876, 3.5), Point::new(9.6111, 3.5)));
let mask = path.extras().mask.as_ref().unwrap();
assert_eq!(first_anchor(&mask.art).p, Point::new(7.7778, 1.0));
let a = first_anchor(&back.symbols[0].art);
assert_eq!((a.p, a.h_out), (Point::new(1.2346, 0.0), Point::new(7.358, 0.0)));
let brush = &back.unknown["brushes"][0]["art"]["kind"]["path"]["subpaths"][0]["anchors"][0];
assert_eq!((brush["p"][0].as_f64(), brush["out"][0].as_f64()), (Some(5.5556), Some(11.679)));
// Colors and transforms keep every digit.
let original = &d.layers[0].children().unwrap()[0];
assert_eq!(path.appearance, original.appearance);
assert_eq!(mask.art.appearance, original.extras().mask.as_ref().unwrap().art.appearance);
let NodeKind::SymbolInstance { xf, .. } = &back.layers[0].children().unwrap()[1].kind else { panic!("not an instance") };
assert_eq!(xf.as_coeffs()[4], 0.123456789);
// The document saved is left as it was.
assert_eq!(first_anchor(&d.layers[0].children().unwrap()[0]).p.x, 3.4876474632797176);
}
#[test]
fn precision_rounds_the_paths_objects_keep_besides_their_own() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let path = || PathData::single(SubPath::polyline(&[Point::new(1.23456789, 0.0), Point::new(9.87654321, 5.0)], false));
// Live Corners' uncut outline, and Intertwine areas of tiers 1 and 2.
let mut n = precise_path(&mut d, 1.0, 1.0);
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = Some(Box::new(LiveShape::Path { base: path(), radii: vec![2.0], kinds: vec![], radii_relative: false }));
}
n.extras_mut().intertwine = Some(Box::new(path()));
n.extras_mut().intertwine_tiers = vec![path()];
d.insert(Some(layer), 0, n).unwrap();
// A blend's replaced spine and an envelope's top-object outline.
let keys = vec![Arc::new(precise_path(&mut d, 1.0, 1.0)), Arc::new(precise_path(&mut d, 50.0, 1.0))];
let blend = BlendSpec { spine: Some(path()), ..BlendSpec::default() };
let id = d.alloc_id();
d.insert(Some(layer), 1, Node::new(id, NodeKind::Blend { children: keys, spec: Box::new(blend) })).unwrap();
let envelope = NodeKind::Envelope {
content: vec![Arc::new(precise_path(&mut d, 1.0, 1.0))],
kind: Box::new(EnvelopeKind::TopObject { path: path(), corners: None }),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Default::default(),
};
let id = d.alloc_id();
d.insert(Some(layer), 2, Node::new(id, envelope)).unwrap();
// The same three objects as brushes' art, and type, whose frame stays exact.
let mut brushes: Vec<Value> = d.layers[0].children().unwrap().iter().map(|n| serde_json::json!({"art": n})).collect();
let frame = serde_json::json!({"subpaths": [{"anchors": [{"p": [1.23456789, 0.0]}], "closed": true}]});
brushes.push(serde_json::json!({"art": {"id": 99, "kind": {"type": "text", "kind": "area", "frame": frame}}}));
d.unknown.insert("brushes".into(), Value::Array(brushes));
let text = |precision| String::from_utf8(save_with(&d, &SaveOptions { precision, ..SaveOptions::default() }).unwrap()).unwrap();
let exact = text(None);
assert_eq!((exact.matches("1.23456789").count(), exact.matches("9.87654321").count()), (11, 10), "{exact}");
let rounded = text(Some(4));
assert_eq!((rounded.matches("1.23456789").count(), rounded.matches("9.87654321").count()), (1, 0), "{rounded}");
assert_eq!((rounded.matches("1.2346").count(), rounded.matches("9.8765").count()), (10, 10), "{rounded}");
}
#[test]
fn without_precision_coordinates_are_written_exactly() {
let d = precise_doc();
let exact = save_with(&d, &SaveOptions::default()).unwrap();
let text = String::from_utf8(exact.clone()).unwrap();
for x in ["3.4876474632797176", "3.499999999999999", "7.77777777", "1.23456789", "5.55555555"] {
assert!(text.contains(x), "{x}");
}
assert_eq!(first_anchor(&load(&exact).unwrap().layers[0].children().unwrap()[0]).p.x, 3.4876474632797176);
// A precision over MAX_PRECISION rounds to MAX_PRECISION decimals.
let at = |p| save_with(&d, &SaveOptions { precision: Some(p), ..SaveOptions::default() }).unwrap();
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<u8>) -> 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<Point> = (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<Point> = 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(), [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::<Vec<_>>();
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);
}