mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 11:55:23 +00:00
Ruler guides were a list kept beside the layers. Each guide is now an object of its layer or sublayer, always directly in it and above the layer's art. These behave as on main: - Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All, Select All, the art bounds, clicks and marquees, and a layer's Pathfinder and Transform effects leave them out. The Layers panel lists them as guide rows of their layer, the highest guide.list index first. - Deleting a layer deletes its guides, and merging layers keeps their guides in the merged layer. - The guide commands number guides as guide.list does, in the order they were made. New: each guide takes an id from the document's object ids. An object command given a guide's id fails with an error, and node.move cannot put a guide into a group or out of its layers. A guide made while an opacity mask, a symbol or a pattern is edited moves to the top visible, unlocked layer when editing ends. File format: native files still save the guides in the document's guides list, in every file version, so older builds read them. Each entry may now also carry the guide's id, name, locked and hidden flags; older builds ignore those fields. Opening a file puts each saved guide on its layer (on the top visible, unlocked layer when the file names none or a layer it does not have), and each saved guide counts against the object limit for opening a file. A ruler guide node that a file holds below a layer's art moves up to the layer's guides, and one anywhere else (in a group or another object, beside the layers, or in a symbol) is removed. Ruler guides read from an .ai file become guide objects on the layer they were on, numbered in the order the file lists them.
180 lines
8.6 KiB
Rust
180 lines
8.6 KiB
Rust
//! Object IDs: names survive the round trip (Layer Names), Minimal writes only referenced ids,
|
|
//! Unique ids never collide, and other apps' label attributes become object names.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
|
use vectorcraft_doc::{Appearance, Document, Node};
|
|
use vectorcraft_geom::{Rect, shapes};
|
|
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import};
|
|
|
|
/// A document with a named rectangle, an unnamed gradient-filled one and a second layer.
|
|
fn doc(title: &str, shift: f64) -> Document {
|
|
let mut d = Document::new(200.0, 200.0);
|
|
d.title = title.into();
|
|
let l = d.layers[0].id;
|
|
let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0 + shift, 10.0, 60.0, 60.0)), Appearance::default_art());
|
|
a.name = Some("Logo mark (v2)".into());
|
|
d.insert(Some(l), usize::MAX, a).unwrap();
|
|
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
|
let b = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(80.0, 10.0, 120.0, 60.0)), Appearance::basic(g, Paint::None, 0.0));
|
|
d.insert(Some(l), usize::MAX, b).unwrap();
|
|
d.add_layer(Some("Top & Front"));
|
|
let top = d.layers[1].id;
|
|
let c = Node::path(
|
|
d.alloc_id(),
|
|
shapes::ellipse(Rect::new(0.0, 100.0, 50.0, 150.0)),
|
|
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
|
);
|
|
d.insert(Some(top), usize::MAX, c).unwrap();
|
|
d
|
|
}
|
|
|
|
/// Every `id="…"` value.
|
|
fn ids(svg: &str) -> Vec<String> {
|
|
svg.split(" id=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect()
|
|
}
|
|
|
|
fn names(d: &Document) -> Vec<String> {
|
|
let mut v = vec![];
|
|
d.walk(|n| v.extend(n.name.clone()));
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn layer_names_round_trip_exactly() {
|
|
let d = doc("a", 0.0);
|
|
let s = export(&d, &ExportOptions::default());
|
|
assert!(s.contains("id=\"Layer_1\" data-name=\"Layer 1\""), "{s}");
|
|
assert!(s.contains("data-name=\"Top & Front\""), "{s}");
|
|
let back = import(&s).unwrap();
|
|
assert_eq!(back.layers.iter().map(|l| l.name.clone().unwrap()).collect::<Vec<_>>(), ["Layer 1", "Top & Front"]);
|
|
assert!(names(&back).contains(&"Logo mark (v2)".to_string()), "{:?}", names(&back));
|
|
// An id that is the name already gets no data-name.
|
|
let mut d = Document::new(10.0, 10.0);
|
|
std::sync::Arc::make_mut(&mut d.layers[0]).name = Some("Layer_1".into());
|
|
let s = export(&d, &ExportOptions::default());
|
|
assert!(s.contains("<g id=\"Layer_1\">"), "{s}");
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_writes_only_referenced_ids() {
|
|
let s = export(&doc("a", 0.0), &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
|
|
let ids = ids(&s);
|
|
assert!(!ids.is_empty(), "the gradient keeps its id");
|
|
for id in &ids {
|
|
assert!(s.contains(&format!("#{id})")) || s.contains(&format!("\"#{id}\"")), "`{id}` is never referenced:\n{s}");
|
|
}
|
|
assert!(!s.contains("data-name"));
|
|
}
|
|
|
|
#[test]
|
|
fn unique_ids_never_collide() {
|
|
let opts = ExportOptions { object_ids: ObjectIds::Unique, styling: Styling::InternalCss, ..Default::default() };
|
|
let a = export(&doc("a", 0.0), &opts);
|
|
let b = export(&doc("b", 5.0), &opts);
|
|
let (ia, ib) = (ids(&a), ids(&b));
|
|
assert_eq!(ia.len(), ia.iter().collect::<HashSet<_>>().len(), "unique within a document");
|
|
assert!(ia.iter().all(|i| !ib.contains(i)), "{ia:?} vs {ib:?}");
|
|
// CSS class names are page-global too.
|
|
let classes = |s: &str| s.split("class=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect::<HashSet<_>>();
|
|
assert!(classes(&a).is_disjoint(&classes(&b)));
|
|
// The same document gets the same ids, and the names still come back.
|
|
assert_eq!(a, export(&doc("a", 0.0), &opts));
|
|
let back = import(&a).unwrap();
|
|
assert_eq!(back.layers[0].name.as_deref(), Some("Layer 1"));
|
|
assert!(names(&back).contains(&"Logo mark (v2)".to_string()));
|
|
}
|
|
|
|
/// Generated def ids count up from 1 and step over numbers an object name already took, also
|
|
/// when thousands of them are written.
|
|
#[test]
|
|
fn generated_ids_count_up_past_taken_names() {
|
|
let mut d = Document::new(200.0, 200.0);
|
|
let l = d.layers[0].id;
|
|
for name in ["linear-gradient-2", "linear-gradient-4"] {
|
|
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art());
|
|
n.name = Some(name.into());
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
}
|
|
let count = 3000;
|
|
for i in 0..count {
|
|
let stops = vec![
|
|
GradientStop::new(0.0, Color::rgb((i % 50) as f32 / 255.0, (i / 50) as f32 / 255.0, 0.0)),
|
|
GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)),
|
|
];
|
|
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
|
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0));
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
}
|
|
for object_ids in [ObjectIds::LayerNames, ObjectIds::Unique] {
|
|
let s = export(&d, &ExportOptions { object_ids, ..Default::default() });
|
|
let all = ids(&s);
|
|
assert_eq!(all.len(), all.iter().collect::<HashSet<_>>().len(), "unique ids");
|
|
let prefix = all.iter().find_map(|i| i.find("Layer_1").map(|at| i[..at].to_string())).unwrap();
|
|
let numbers: Vec<usize> =
|
|
all.iter().filter_map(|i| i.strip_prefix(&format!("{prefix}linear-gradient-"))).map(|n| n.parse().unwrap()).collect();
|
|
assert_eq!(numbers.len(), count + 2, "the two named objects and every gradient");
|
|
let mut gradients: Vec<usize> = numbers.into_iter().filter(|n| *n != 2 && *n != 4).collect();
|
|
gradients.sort_unstable();
|
|
let want: Vec<usize> = [1, 3].into_iter().chain(5..count + 3).collect();
|
|
assert_eq!(gradients, want);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn label_attributes_name_objects() {
|
|
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:serif="http://www.serif.com/" width="100" height="100">
|
|
<g id="layer1" inkscape:label="Background">
|
|
<rect id="r1" data-name="Sky box" x="0" y="0" width="10" height="10"/>
|
|
<rect id="r2" serif:id="Sun disc" x="20" y="0" width="10" height="10"/>
|
|
<rect id="r3" aria-label="Badge" x="40" y="0" width="10" height="10"/>
|
|
<rect id="plain" x="60" y="0" width="10" height="10"/>
|
|
</g>
|
|
</svg>"##;
|
|
let d = import(svg).unwrap();
|
|
assert_eq!(d.layers[0].name.as_deref(), Some("Background"));
|
|
assert_eq!(names(&d), ["Background", "Sky box", "Sun disc", "Badge", "plain"]);
|
|
}
|
|
|
|
#[test]
|
|
fn unique_ids_differ_when_only_gradient_stops_differ() {
|
|
let make_doc = |stop_color: Color| {
|
|
let mut d = Document::new(200.0, 200.0);
|
|
d.title = "Identical Structure".into();
|
|
let l = d.layers[0].id;
|
|
let stops = vec![GradientStop::new(0.0, stop_color), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
|
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
|
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), Appearance::basic(g, Paint::None, 0.0));
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
d
|
|
};
|
|
|
|
let opts = ExportOptions { object_ids: ObjectIds::Unique, ..Default::default() };
|
|
let svg1 = export(&make_doc(Color::rgb(1.0, 0.0, 0.0)), &opts);
|
|
let svg2 = export(&make_doc(Color::rgb(0.0, 1.0, 0.0)), &opts);
|
|
|
|
let ids1 = ids(&svg1);
|
|
let ids2 = ids(&svg2);
|
|
assert!(!ids1.is_empty() && !ids2.is_empty());
|
|
assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}");
|
|
}
|
|
|
|
/// Ruler guides on each layer, named as a layer and an object above them are, with a URL, are not
|
|
/// written: the SVG is the same as without them, with every kind of object id.
|
|
#[test]
|
|
fn ruler_guides_are_not_written_and_take_no_id() {
|
|
let plain = doc("a", 0.0);
|
|
let mut guided = plain.clone();
|
|
for (i, (l, name)) in [(guided.layers[0].id, "Top & Front"), (guided.layers[1].id, "Logo mark (v2)")].into_iter().enumerate() {
|
|
let mut g = Node::new(guided.alloc_id(), vectorcraft_doc::NodeKind::RulerGuide { vertical: i == 0, pos: 30.0, artboard: None });
|
|
g.name = Some(name.into());
|
|
g.edit_attrs(|a| a.url = "https://example.com/".into());
|
|
guided.insert(Some(l), usize::MAX, g).unwrap();
|
|
}
|
|
for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] {
|
|
let opts = ExportOptions { object_ids, ..Default::default() };
|
|
assert_eq!(export(&guided, &opts), export(&plain, &opts), "{object_ids:?}");
|
|
}
|
|
}
|