mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:12:11 +00:00
357 lines
15 KiB
Rust
357 lines
15 KiB
Rust
//! Artwork Cleanup's commands: Simplify Anchors (Keep Shape), the health check's kinds found and
|
|
//! fixed one by one, Fix as one undo step, scopes, Show, Deep Ungroup and Rejoin Path Pieces.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
|
use vectorcraft_geom::{FillRule, Rect};
|
|
|
|
use crate::Session;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn id_of(v: &Value) -> NodeId {
|
|
NodeId(v["id"].as_u64().unwrap())
|
|
}
|
|
|
|
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
|
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
|
}
|
|
|
|
/// A path through `pts` (anchors as `{x, y, out?}` objects).
|
|
fn path(s: &mut Session, anchors: Value, closed: bool) -> NodeId {
|
|
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
|
}
|
|
|
|
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
|
|
path(s, pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(), false)
|
|
}
|
|
|
|
fn node(s: &Session, id: NodeId) -> Option<Node> {
|
|
s.doc().unwrap().doc.node(id).cloned()
|
|
}
|
|
|
|
fn change(s: &mut Session, id: NodeId, f: impl FnOnce(&mut Node)) {
|
|
s.edit("t", |d, _| {
|
|
f(d.node_mut(id).unwrap());
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
/// Put `id` inside a new container that `kind` makes of its members.
|
|
fn wrap(s: &mut Session, id: NodeId, kind: fn(Vec<std::sync::Arc<Node>>) -> NodeKind) -> NodeId {
|
|
s.edit("t", |d, _| {
|
|
let w = d.alloc_id();
|
|
Ok(d.wrap(&[id], |c| Node::new(w, kind(c)))?)
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
fn select(s: &mut Session, ids: &[NodeId]) {
|
|
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
|
|
}
|
|
|
|
fn anchors(s: &Session, id: NodeId) -> usize {
|
|
node(s, id).unwrap().path_data().unwrap().anchor_count()
|
|
}
|
|
|
|
/// How many issues of `kind` a check of `scope` finds.
|
|
fn found(s: &mut Session, kind: &str, scope: &str) -> usize {
|
|
let r = s.execute("addon.cleanup.check", &json!({"kinds": [kind], "scope": scope})).unwrap();
|
|
r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == kind).map_or(0, |f| f["count"].as_u64().unwrap() as usize)
|
|
}
|
|
|
|
fn undo_steps(s: &Session) -> usize {
|
|
s.doc().unwrap().history.undo.len()
|
|
}
|
|
|
|
#[test]
|
|
fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() {
|
|
let mut s = session();
|
|
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
|
|
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
|
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
|
assert_eq!(anchors(&s, e), 16);
|
|
let bounds = node(&s, e).unwrap().geometric_bounds().unwrap();
|
|
let steps = undo_steps(&s);
|
|
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
|
|
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(16), Some(anchors(&s, e) as u64)));
|
|
assert!(anchors(&s, e) <= 8, "{r}");
|
|
let now = node(&s, e).unwrap().geometric_bounds().unwrap();
|
|
for (a, b) in [(bounds.x0, now.x0), (bounds.y0, now.y0), (bounds.x1, now.x1), (bounds.y1, now.y1)] {
|
|
assert!((a - b).abs() < 0.4, "{bounds:?} → {now:?}");
|
|
}
|
|
assert_eq!(undo_steps(&s), steps + 1);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(anchors(&s, e), 16);
|
|
// A rectangle's corners stay; the anchors on its sides go.
|
|
let r = rect(&mut s, 200.0, 50.0, 100.0, 60.0);
|
|
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
|
assert_eq!(anchors(&s, r), 8);
|
|
s.execute("addon.cleanup.simplify", &json!({"protectCorners": true})).unwrap();
|
|
assert_eq!(anchors(&s, r), 4);
|
|
// Nothing left to remove: no undo step.
|
|
let steps = undo_steps(&s);
|
|
s.execute("addon.cleanup.simplify", &json!({})).unwrap();
|
|
assert_eq!(undo_steps(&s), steps);
|
|
for bad in [json!({"tolerance": 0}), json!({"tolerance": 101}), json!({"tolerance": "20"}), json!({"protectCorners": 1})] {
|
|
assert!(s.execute("addon.cleanup.simplify", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
|
|
fn make_issue(s: &mut Session, kind: &str) {
|
|
match kind {
|
|
"emptyText" => {
|
|
s.execute("text.create", &json!({"x": 20, "y": 20, "text": " "})).unwrap();
|
|
}
|
|
"strayPoints" => {
|
|
path(s, json!([{"x": 30, "y": 30}]), false);
|
|
}
|
|
"unpainted" => {
|
|
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
|
change(s, id, |n| n.appearance = Appearance::default().into());
|
|
}
|
|
"zeroOpacity" => {
|
|
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
|
change(s, id, |n| n.opacity = 0.0);
|
|
}
|
|
"duplicateObjects" => {
|
|
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
|
s.edit("t", |d, _| {
|
|
let mut copy = d.node(id).unwrap().clone();
|
|
copy.id = d.alloc_id();
|
|
let (parent, i, _) = d.position(id).unwrap();
|
|
Ok(d.insert(parent, i + 1, copy)?)
|
|
})
|
|
.unwrap();
|
|
}
|
|
"offArtboard" => {
|
|
rect(s, -500.0, -500.0, 30.0, 30.0);
|
|
}
|
|
"duplicateAnchors" => {
|
|
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.005, 150.0), (50.0, 190.0)]);
|
|
}
|
|
"tinyHandles" => {
|
|
path(s, json!([{"x": 10, "y": 150, "out": [10.004, 150]}, {"x": 60, "y": 190}]), false);
|
|
}
|
|
"collinearAnchors" => {
|
|
line(s, &[(10.0, 150.0), (30.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
|
|
}
|
|
"nearPoints" => {
|
|
line(s, &[(10.0, 150.0), (50.0, 150.0)]);
|
|
line(s, &[(50.005, 150.0), (90.0, 190.0)]);
|
|
}
|
|
"nearlyClosed" => {
|
|
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0), (10.005, 150.0)]);
|
|
}
|
|
"filledOpenPaths" => {
|
|
let id = line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
|
|
change(s, id, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0).into());
|
|
}
|
|
"needlessClipGroups" => {
|
|
let inner = rect(s, 30.0, 160.0, 20.0, 20.0);
|
|
let clip = rect(s, 20.0, 150.0, 50.0, 50.0);
|
|
select(s, &[inner, clip]);
|
|
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
|
}
|
|
"needlessCompounds" => {
|
|
// A compound path holds the paint of its paths.
|
|
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
|
let look = node(s, id).unwrap().appearance;
|
|
let compound = wrap(s, id, |children| NodeKind::Compound { children, rule: FillRule::NonZero });
|
|
change(s, compound, |n| n.appearance = look);
|
|
}
|
|
"needlessGroups" => {
|
|
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
|
wrap(s, id, |children| NodeKind::Group { children, clip: false });
|
|
}
|
|
"emptyArtboards" => {
|
|
s.edit("t", |d, _| {
|
|
let mut a = d.artboards[0].clone();
|
|
a.id = d.next_artboard_id();
|
|
a.rect = Rect::new(1000.0, 1000.0, 1100.0, 1100.0);
|
|
d.artboards.push(a);
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
}
|
|
_ => panic!("no setup for {kind}"),
|
|
}
|
|
}
|
|
|
|
const KINDS: [&str; 16] = [
|
|
"emptyText",
|
|
"strayPoints",
|
|
"unpainted",
|
|
"zeroOpacity",
|
|
"duplicateObjects",
|
|
"offArtboard",
|
|
"duplicateAnchors",
|
|
"tinyHandles",
|
|
"collinearAnchors",
|
|
"nearPoints",
|
|
"nearlyClosed",
|
|
"filledOpenPaths",
|
|
"needlessClipGroups",
|
|
"needlessCompounds",
|
|
"needlessGroups",
|
|
"emptyArtboards",
|
|
];
|
|
|
|
#[test]
|
|
fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
|
|
let mut clean = session();
|
|
let r = rect(&mut clean, 200.0, 20.0, 80.0, 60.0);
|
|
let before = node(&clean, r).unwrap();
|
|
assert_eq!(clean.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "clean art has no issues");
|
|
assert_eq!(clean.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0);
|
|
assert_eq!(node(&clean, r).unwrap(), before);
|
|
for kind in KINDS {
|
|
let mut s = session();
|
|
let r = rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
|
make_issue(&mut s, kind);
|
|
select(&mut s, &[]);
|
|
assert!(found(&mut s, kind, "document") > 0, "{kind} not found");
|
|
let fixed = s.execute("addon.cleanup.fix", &json!({"kinds": [kind], "scope": "document"})).unwrap();
|
|
assert!(fixed["fixed"][kind].as_u64().unwrap() > 0, "{kind}: {fixed}");
|
|
assert_eq!(found(&mut s, kind, "document"), 0, "{kind} still found after Fix");
|
|
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "{kind} left other issues");
|
|
assert_eq!(node(&s, r).unwrap(), before, "{kind} changed the clean rectangle");
|
|
}
|
|
}
|
|
|
|
/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point:
|
|
/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays.
|
|
#[test]
|
|
fn doubled_type_within_a_point_is_a_duplicate() {
|
|
let mut s = session();
|
|
let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap());
|
|
let base = text(&mut s, 20.0, "Total");
|
|
let doubled = text(&mut s, 20.4, "Total");
|
|
let other = text(&mut s, 20.0, "Subtotal");
|
|
let far = text(&mut s, 23.0, "Subtotal");
|
|
assert_eq!(found(&mut s, "duplicateObjects", "document"), 1);
|
|
let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap();
|
|
assert_eq!(shown["ids"], json!([doubled.0]));
|
|
s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap();
|
|
assert!(node(&s, doubled).is_none());
|
|
for id in [base, other, far] {
|
|
assert!(node(&s, id).is_some(), "{id:?} stays");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fix_all_is_one_undo_step() {
|
|
let mut s = session();
|
|
for kind in KINDS {
|
|
make_issue(&mut s, kind);
|
|
}
|
|
select(&mut s, &[]);
|
|
let total = s.execute("addon.cleanup.check", &json!({})).unwrap()["total"].as_u64().unwrap();
|
|
assert!(total >= 16, "{total}");
|
|
let (steps, doc) = (undo_steps(&s), s.doc().unwrap().doc.clone());
|
|
let r = s.execute("addon.cleanup.fix", &json!({})).unwrap();
|
|
assert_eq!(r["scope"], "document");
|
|
assert_eq!(undo_steps(&s), steps + 1);
|
|
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.layers, doc.layers);
|
|
assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len());
|
|
}
|
|
|
|
#[test]
|
|
fn the_scope_is_the_selection_or_the_document() {
|
|
let mut s = session();
|
|
let a = path(&mut s, json!([{"x": 30, "y": 30}]), false);
|
|
let b = path(&mut s, json!([{"x": 60, "y": 30}]), false);
|
|
select(&mut s, &[a]);
|
|
let r = s.execute("addon.cleanup.check", &json!({"kinds": ["strayPoints"]})).unwrap();
|
|
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("selection"), Some(1)));
|
|
assert_eq!(found(&mut s, "strayPoints", "document"), 2);
|
|
s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"]})).unwrap();
|
|
assert!(node(&s, a).is_none() && node(&s, b).is_some(), "only the selection is fixed");
|
|
// Nothing selected: the document.
|
|
let r = s.execute("addon.cleanup.check", &json!({})).unwrap();
|
|
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("document"), Some(1)));
|
|
assert!(s.execute("addon.cleanup.check", &json!({"scope": "selection"})).is_err(), "nothing selected");
|
|
for bad in [json!({"scope": "page"}), json!({"kinds": ["nope"]}), json!({"kinds": "strayPoints"}), json!({"kinds": [1]})] {
|
|
assert!(s.execute("addon.cleanup.check", &bad).is_err(), "{bad}");
|
|
assert!(s.execute("addon.cleanup.fix", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn show_selects_the_objects_with_the_issue() {
|
|
let mut s = session();
|
|
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
|
let stray = path(&mut s, json!([{"x": 30, "y": 30}]), false);
|
|
select(&mut s, &[]);
|
|
let r = s.execute("addon.cleanup.show", &json!({"kind": "strayPoints"})).unwrap();
|
|
assert_eq!(r["count"], 1);
|
|
assert_eq!(s.doc().unwrap().selection.objects, [stray]);
|
|
assert!(s.execute("addon.cleanup.show", &json!({"kind": "emptyText", "scope": "document"})).is_err(), "no such issue");
|
|
assert!(s.execute("addon.cleanup.show", &json!({})).is_err() && s.execute("addon.cleanup.show", &json!({"kind": "x"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn deep_ungroup_takes_apart_every_level_but_clipping_groups() {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
|
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
|
|
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
|
|
select(&mut s, &[b, c]);
|
|
let inner = id_of(&s.execute("object.group", &json!({})).unwrap());
|
|
change(&mut s, inner, |n| n.opacity = 0.5);
|
|
let clipped = rect(&mut s, 100.0, 10.0, 10.0, 10.0);
|
|
let clip = rect(&mut s, 95.0, 5.0, 30.0, 30.0);
|
|
select(&mut s, &[clipped, clip]);
|
|
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
|
let clip_group = s.doc().unwrap().selection.objects[0];
|
|
select(&mut s, &[a, inner, clip_group]);
|
|
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
|
|
let steps = undo_steps(&s);
|
|
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
|
|
assert_eq!(r["ungrouped"], 2);
|
|
assert_eq!(undo_steps(&s), steps + 1);
|
|
let d = &s.doc().unwrap().doc;
|
|
let layer: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
|
assert_eq!(layer, [a, b, c, clip_group]);
|
|
assert!(d.node(outer).is_none() && d.node(inner).is_none());
|
|
assert_eq!(d.node(b).unwrap().opacity, 0.5, "members keep their groups' opacity");
|
|
assert_eq!(s.doc().unwrap().selection.objects.len(), 4);
|
|
// Asked to, clipping groups come apart too, their clipping paths kept as plain paths.
|
|
select(&mut s, &[clip_group]);
|
|
s.execute("addon.cleanup.deepUngroup", &json!({"includeClipGroups": true})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert!(d.node(clip_group).is_none());
|
|
assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn rejoin_joins_pieces_within_the_gap_only() {
|
|
let mut s = session();
|
|
let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]);
|
|
let b = line(&mut s, &[(50.05, 10.0), (90.0, 50.0)]);
|
|
let far = line(&mut s, &[(92.0, 50.0), (120.0, 50.0)]);
|
|
select(&mut s, &[a, b, far]);
|
|
let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap();
|
|
assert_eq!((r["joined"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)));
|
|
// The topmost piece takes the joined path.
|
|
assert!(node(&s, a).is_none());
|
|
assert_eq!(anchors(&s, b), 3);
|
|
assert_eq!(anchors(&s, far), 2, "beyond the gap");
|
|
select(&mut s, &[b, far]);
|
|
let r = s.execute("addon.cleanup.rejoin", &json!({"gap": 3})).unwrap();
|
|
assert_eq!(r["joined"], 1);
|
|
assert_eq!(anchors(&s, far), 4);
|
|
for bad in [json!({"gap": -1}), json!({"gap": 7}), json!({"gap": "1"})] {
|
|
assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|