Fix #4835: Artwork Cleanup's Duplicate Objects finds type set twice in the same place

This commit is contained in:
bflatastic
2026-10-31 19:43:17 +07:00
parent 437280315f
commit 78a68bd8ec
4 changed files with 54 additions and 5 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Health Check",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
has_doc,
check
),
+32 -3
View File
@@ -235,17 +235,46 @@ fn is_plain(n: &Node) -> bool {
&& *n.extras() == NodeExtras::default()
}
/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it?
/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just
/// below it?
fn duplicates_the_one_below(d: &Document, n: &Node) -> bool {
if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal {
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
return false;
}
let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned());
below.is_some_and(|b| {
b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras()
b.visible
&& same_art(&b.kind, &n.kind)
&& b.appearance == n.appearance
&& b.opacity == n.opacity
&& b.blend == n.blend
&& b.extras() == n.extras()
})
}
/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old
/// file, or for a fake bold, is often a fraction of a point off.
const DOUBLED_TYPE: f64 = 1.0;
/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it.
fn same_art(a: &NodeKind, b: &NodeKind) -> bool {
match (a, b) {
(NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b,
(NodeKind::Text(a), NodeKind::Text(b)) => {
let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs());
[a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && {
// `b` placed on `a`, with `a`'s layout caches (they are in document space).
let mut moved = (**b).clone();
moved.xf = a.xf;
moved.cached_bounds = a.cached_bounds;
moved.cached_baselines.clone_from(&a.cached_baselines);
**a == moved
}
}
_ => false,
}
}
fn inside(a: Rect, b: Rect) -> bool {
a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1
}
@@ -226,6 +226,26 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
}
}
/// 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();