From 019f604e91f769bd7ba7022157bbf3331adf6b90 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:38:38 -0800 Subject: [PATCH 1/3] Artwork Cleanup: text tools act on the locked or hidden type ids name Remove Breaks, Break Text, Combine Point Text, Join Point Text, Set Alignment, Keep Position and Reset Text Transform skipped locked and hidden type even when `ids` named it, or named the group or layer holding it, while other commands such as text.convertListToText act on named locked or hidden objects. - With `ids`, the type they name is taken locked or hidden too (not type on a locked artboard); from the selection, only type that can be edited, as before. - Remove Breaks reads a named threaded story with a locked frame as one text. - Objects made from locked or hidden type are not selected. Docs: docs/mcp.md (Text tools), docs/target-app-parity.md. --- crates/engine/src/cmd/addons/cleanup/text.rs | 70 ++++++----- .../src/cmd/addons/cleanup/text/tests.rs | 115 ++++++++++++++++++ docs/mcp.md | 4 +- docs/target-app-parity.md | 4 + 4 files changed, 164 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 052ba901ed..0f22b184f3 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Break Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", has_selection, break_text ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Combine Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", has_selection, combine_text ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Join Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", has_selection, join_text ), @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Remove Breaks", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", + "{kind: \"line\"|\"paragraph\", ids?: [id…] (the type they name is taken locked or hidden too)} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}", has_selection, remove_breaks ), @@ -90,7 +90,7 @@ pub fn specs() -> Vec { "Set Alignment, Keep Position", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", has_selection, align_in_place ), @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Reset Text Transform", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", has_selection, reset_transform ), @@ -123,8 +123,17 @@ fn editing_text(s: &Session) -> std::result::Result<(), String> { } } -/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. -fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { +/// The type objects a command acts on: those under the objects `ids` names, locked and hidden ones +/// included, else those under the selection that can be edited ([`texts_under`]). +fn target_texts(s: &Session, p: &Value, command: &str) -> Result> { + let named = p.get("ids").is_some_and(|v| !v.is_null()); + texts_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, command)?, named) +} + +/// The type objects under `roots` (inside groups and layers, not live art). When `named`, every one +/// not on a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer) +/// included; else only those that can be edited. +fn texts_under(d: &Document, roots: &[NodeId], named: bool) -> Result> { fn visit(n: &Node, out: &mut Vec) { match &n.kind { NodeKind::Text(_) => out.push(n.id), @@ -134,8 +143,13 @@ fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { } let mut out = vec![]; roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); - let editable = d.editable(); - out.retain(|id| editable(*id)); + if named { + let locks = d.locked_artboards(); + out.retain(|id| !locks.holds_id(*id)); + } else { + let editable = d.editable(); + out.retain(|id| editable(*id)); + } out.dedup(); if out.len() > MAX_TEXTS { return Err(EngineError::Other("Too many type objects to change at once".into())); @@ -376,7 +390,9 @@ fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, V places.push((id, nodes)); } d.insert_beside(places, true)?; - sel.set(made.iter().copied()); + // Pieces of locked or hidden type stay unselected. + let editable = d.editable(); + sel.set(made.iter().copied().filter(|id| editable(*id))); Ok(made.iter().map(|i| i.0).collect()) }) } @@ -396,7 +412,7 @@ fn break_text(s: &mut Session, p: &Value) -> Result { }; let d = &s.doc()?.doc; let (mut items, mut skipped, mut count) = (vec![], vec![], 0); - let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let ids = target_texts(s, p, C)?; let threads = thread_index(d); for id in ids { let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else { @@ -595,8 +611,7 @@ fn merge(chain: &[&Piece]) -> std::result::Result { fn combine_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.combineText"; let d = &s.doc()?.doc; - let mut pieces: Vec<(Piece, &Node)> = - texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + let mut pieces: Vec<(Piece, &Node)> = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); // Lines: pieces turned alike and looking alike, by baseline, then left to right. pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); let mut lines: Vec> = vec![]; @@ -672,7 +687,8 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { gone.extend(others); } d.remove_many(&gone)?; - sel.set(ids.iter().map(|i| NodeId(*i))); + let editable = d.editable(); + sel.set(ids.iter().map(|i| NodeId(*i)).filter(|id| editable(*id))); Ok(()) })?; Ok(json!({ "ids": ids, "combined": combined })) @@ -681,8 +697,7 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { fn join_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.joinText"; let d = &s.doc()?.doc; - let pieces: Vec = - texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); + let pieces: Vec = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); // The piece top-left on the page sets the turn; the others turned like it are read in that frame. let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; let first = pieces.iter().min_by(|x, y| { @@ -751,7 +766,7 @@ fn join_text(s: &mut Session, p: &Value) -> Result { n.kind = NodeKind::Text(Box::new(m)); } d.remove_many(&gone)?; - sel.set([id]); + sel.set([id].into_iter().filter(|id| d.is_editable(*id))); Ok(()) })?; Ok(json!({ "id": id.0, "joined": joined, "lines": count })) @@ -797,8 +812,8 @@ fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize { /// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or /// start of a frame between words of the frames on either side becomes a space. Each editable frame -/// comes with its own breaks, at its own bytes. -fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> { +/// (each frame when `all`) comes with its own breaks, at its own bytes. +fn story_cuts(d: &Document, story: &[NodeId], brk: char, all: bool) -> Vec<(NodeId, Vec<(usize, bool)>)> { let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect(); let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect(); // The breaks in story order, handed to the frames in one pass. @@ -811,7 +826,7 @@ fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(us while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) { own.push((at - start, space)); } - if d.is_editable(id) { + if all || d.is_editable(id) { out.push((id, own)); } start = end; @@ -834,14 +849,14 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // Each type object with the breaks that go from it. let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing { - Some(_) if !story.is_empty() => story_cuts(d, &story, brk), + Some(_) if !story.is_empty() => story_cuts(d, &story, brk, false), Some((id, a, b)) => vec![(id, a.min(b), a.max(b))] .into_iter() .filter(|(id, ..)| d.is_editable(*id)) .filter_map(|(id, a, b)| Some((id, break_cuts(&text_of(d, id)?.plain_text(), a, b, brk)))) .collect(), None => { - let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let ids = target_texts(s, p, C)?; // A threaded story whose frames are all among them is read as one text, once. let named: std::collections::HashSet = ids.iter().copied().collect(); let threads = thread_index(d); @@ -857,7 +872,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { done.extend(story.iter().copied()); - out.extend(story_cuts(d, &story, brk)); + // Every frame is among them, so each one keeps its breaks. + out.extend(story_cuts(d, &story, brk, true)); } else if let Some(t) = text_of(d, id) { out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk))); } @@ -891,10 +907,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { /// Horizontal point type among the objects `p` names. fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { let d = &s.doc()?.doc; - let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? - .into_iter() - .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) - .collect(); + let ids: Vec = + target_texts(s, p, cmd)?.into_iter().filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))).collect(); if ids.is_empty() { return Err(EngineError::Other("Select point type".into())); } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index cfa78a5ce5..cf5f95a667 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -379,6 +379,121 @@ fn remove_breaks_reads_the_threads_once() { } } +/// Lock `id` (`locked`), else hide it, as an undo step of its own. +fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) { + s.edit("Lock or Hide", |d, _| { + let n = d.node_mut(id).unwrap(); + if locked { + n.locked = true; + } else { + n.visible = false; + } + Ok(()) + }) + .unwrap(); +} + +/// Remove Breaks takes the locked and hidden type that `ids` names, and the locked member of a +/// group it names, as one undo step; from the selection it takes only type that can be edited, and +/// nothing changed makes no undo step. +#[test] +fn remove_breaks_named_locked_and_hidden_type() { + let mut s = session(); + let locked = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12})); + let hidden = text(&mut s, json!({"x": 40, "y": 120, "text": "c\nd", "size": 12})); + let grouped = text(&mut s, json!({"x": 40, "y": 180, "text": "e\nf", "size": 12})); + let free = text(&mut s, json!({"x": 40, "y": 240, "text": "g\nh", "size": 12})); + select(&mut s, &[grouped, free]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + lock_or_hide(&mut s, locked, true); + lock_or_hide(&mut s, hidden, false); + lock_or_hide(&mut s, grouped, true); + select(&mut s, &[locked, hidden]); + let steps = undo_steps(&s); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"}))), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps, "the selected locked and hidden type stays as it is"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0, hidden.0, group.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(r["removed"], 4); + for (id, now) in [(locked, "a b"), (hidden, "c d"), (grouped, "e f"), (free, "g h")] { + assert_eq!(obj(&s, id).plain_text(), now); + } + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [locked.0]}))), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps + 1, "nothing changed, no undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, locked).plain_text(), "a\nb"); +} + +/// A threaded story whose frames `ids` all names is read as one text even when a frame is locked. +#[test] +fn remove_breaks_named_story_with_a_locked_frame() { + let mut s = session(); + let (a, b) = threaded(&mut s, "one\ntwo"); + lock_or_hide(&mut s, a, true); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [a.0, b.0]})).unwrap(); + assert_eq!(r["removed"], 1); + assert_eq!(obj(&s, a).plain_text(), "one two"); +} + +/// Break Text, Combine and Join Point Text, Set Alignment, Keep Position and Reset Text Transform +/// act on the locked and hidden type that `ids` names (type in a locked layer included), each as +/// one undo step, and leave it as it is when it is only selected. +#[test] +fn named_locked_and_hidden_type() { + let mut s = session(); + let words = text(&mut s, json!({"x": 40, "y": 100, "text": "Two words", "size": 14})); + lock_or_hide(&mut s, words, true); + select(&mut s, &[words]); + assert!(s.execute("addon.cleanup.breakText", &json!({"by": "words"})).is_err()); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words", "ids": [words.0]})).unwrap(); + assert_eq!((ids(&r).len(), undo_steps(&s)), (2, steps + 1)); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked pieces stay unselected"); + + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + lock_or_hide(&mut s, a, false); + lock_or_hide(&mut s, b, false); + select(&mut s, &[a, b]); + assert!(s.execute("addon.cleanup.combineText", &json!({})).is_err()); + let r = s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden object stays unselected"); + + let mut s = session(); + let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let second = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20})); + let layer = s.doc().unwrap().doc.layers[0].id; + lock_or_hide(&mut s, layer, true); + select(&mut s, &[first, second]); + assert!(s.execute("addon.cleanup.joinText", &json!({})).is_err()); + let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, second.0]})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(first.0)); + assert_eq!(obj(&s, first).plain_text(), "First\nSecond"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the object in the locked layer stays unselected"); + + let mut s = session(); + let lines = text(&mut s, json!({"x": 50, "y": 100, "text": "a\nlonger line", "size": 20})); + lock_or_hide(&mut s, lines, false); + select(&mut s, &[lines]); + assert!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "center"})).is_err()); + s.execute("addon.cleanup.alignInPlace", &json!({"align": "center", "ids": [lines.0]})).unwrap(); + assert!(obj(&s, lines).para_styles().all(|p| p.justify == Justify::Center)); + + let mut s = session(); + let turned = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned", "size": 18, "rotation": 30})); + lock_or_hide(&mut s, turned, true); + select(&mut s, &[turned]); + assert!(s.execute("addon.cleanup.resetTextTransform", &json!({})).is_err()); + let steps = undo_steps(&s); + s.execute("addon.cleanup.resetTextTransform", &json!({"ids": [turned.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let [_, b, c, _, _, _] = obj(&s, turned).xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "the rotation is gone"); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..a9b621b298 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10642,7 +10642,9 @@ topmost piece. ### Text tools The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there -is nothing for them to do. The pieces they make are point type that keeps its characters' styles, each placed so the +is nothing for them to do. From the selection they take only type that can be edited; the type that `ids` names, or +that is inside a group or layer it names, is taken even when it, its group or its layer is locked or hidden (not type +on a locked artboard). The objects they make from locked or hidden type are not selected. The pieces they make are point type that keeps its characters' styles, each placed so the glyph of its first character lands where it was drawn. - `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..a5953d677e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2048,6 +2048,10 @@ Line / Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and the panel's Text row run them) and `cleanup_text_tools_with_junk_editing_ranges` in the command sweep. +With `ids`, the text tools act on the type the ids name even when it, or its group or layer, is locked or hidden, as +`text.convertListToText` does; from the selection they still take only type that can be edited (VectorCraft's own +rule). Tests: `named_locked_and_hidden_type` and the other `named_` tests in `cmd::addons::cleanup::text::tests`. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, From e76982bfadd09477f69a1df3225ccbc0ffe63720 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 23:08:11 -0800 Subject: [PATCH 2/3] Artwork Cleanup: named type on a locked artboard stays out of the text tools Add a test that Remove Breaks leaves type on a locked artboard as it is when ids names it, with no undo step. Rewrap the Text tools paragraph in docs/mcp.md and name the tests in docs/target-app-parity.md. --- .../engine/src/cmd/addons/cleanup/text/tests.rs | 17 +++++++++++++++++ docs/mcp.md | 4 ++-- docs/target-app-parity.md | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index cf5f95a667..6b03a26384 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -435,6 +435,23 @@ fn remove_breaks_named_story_with_a_locked_frame() { assert_eq!(obj(&s, a).plain_text(), "one two"); } +/// Type on a locked artboard stays as it is even when `ids` names it, with no undo step. +#[test] +fn remove_breaks_named_type_on_a_locked_artboard() { + let mut s = session(); + let on_board = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb", "size": 12})); + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": [on_board.0]})); + assert_eq!(other(r), "No paragraph breaks to remove"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(obj(&s, on_board).plain_text(), "a\nb"); +} + /// Break Text, Combine and Join Point Text, Set Alignment, Keep Position and Reset Text Transform /// act on the locked and hidden type that `ids` names (type in a locked layer included), each as /// one undo step, and leave it as it is when it is only selected. diff --git a/docs/mcp.md b/docs/mcp.md index a9b621b298..3a9a88e7c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10644,8 +10644,8 @@ topmost piece. The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there is nothing for them to do. From the selection they take only type that can be edited; the type that `ids` names, or that is inside a group or layer it names, is taken even when it, its group or its layer is locked or hidden (not type -on a locked artboard). The objects they make from locked or hidden type are not selected. The pieces they make are point type that keeps its characters' styles, each placed so the -glyph of its first character lands where it was drawn. +on a locked artboard). The objects they make from locked or hidden type are not selected. The pieces they make are +point type that keeps its characters' styles, each placed so the glyph of its first character lands where it was drawn. - `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one per line (`by: "lines"`, the default), paragraph (`"paragraphs"`), word (`"words"`) or glyph (`"glyphs"`; a ligature diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a5953d677e..481cd3e9d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2050,7 +2050,8 @@ refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and th With `ids`, the text tools act on the type the ids name even when it, or its group or layer, is locked or hidden, as `text.convertListToText` does; from the selection they still take only type that can be edited (VectorCraft's own -rule). Tests: `named_locked_and_hidden_type` and the other `named_` tests in `cmd::addons::cleanup::text::tests`. +rule). Tests: `named_locked_and_hidden_type`, `remove_breaks_named_locked_and_hidden_type` and +`remove_breaks_named_story_with_a_locked_frame` in `cmd::addons::cleanup::text::tests`. ## Path Precision Tools add-on (2026-10-30) From b1d364033e20ebc7f02c3d1698330537df016d9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:45:50 -0800 Subject: [PATCH 3/3] Artwork Cleanup: Combine and Join Point Text keep hidden and locked pieces apart With ids, Combine Point Text and Join Point Text take locked and hidden type. They kept the first piece's object and removed the others, so naming a visible piece and a hidden one beside it hid the visible text or showed the hidden text, and the same held for locked pieces and for pieces in a locked or hidden group or layer. A piece now joins only pieces that are as visible and as locked as it is, counting their groups and layers. The selection route takes only editable type, so it behaves as before. --- crates/engine/src/cmd/addons/cleanup/text.rs | 26 ++++++--- .../src/cmd/addons/cleanup/text/tests.rs | 58 +++++++++++++++++++ docs/mcp.md | 21 ++++--- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 0f22b184f3..00d75e9888 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Combine Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike, as visible and as locked (their groups and layers included), whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", has_selection, combine_text ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Join Point Text", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", + "{ids?: [id…] (default: the selection; the type they name is taken locked or hidden too)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one, and as visible and as locked (their groups and layers included), are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}", has_selection, join_text ), @@ -466,6 +466,9 @@ fn split_text(s: &mut Session, _: &Value) -> Result { struct Piece { id: NodeId, t: TextObject, + /// Whether the object and every group and layer above it are visible, and whether they are all + /// unlocked. + state: (bool, bool), /// The object's turn and shear (`xf` without its move). turn: [f64; 4], /// Where its first character's glyph starts on its baseline, in that frame. @@ -503,13 +506,19 @@ impl Piece { if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { return None; } - Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + let state = d.ancestry(id)?.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), n| (v && n.visible, u && !n.locked)); + Some((Self { id, t, state, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) } /// Whether `o` is turned and sheared alike. fn turned_like(&self, o: &Piece) -> bool { self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) } + + /// Whether `o` is turned and sheared alike and has the same `state`. + fn joins(&self, o: &Piece) -> bool { + self.turned_like(o) && self.state == o.state + } } /// Whether two objects look alike apart from their text (paint, opacity, blending, effects). @@ -612,13 +621,13 @@ fn combine_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.combineText"; let d = &s.doc()?.doc; let mut pieces: Vec<(Piece, &Node)> = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); - // Lines: pieces turned alike and looking alike, by baseline, then left to right. + // Lines: pieces turned alike, as visible and as locked and looking alike, by baseline, then left + // to right. pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); let mut lines: Vec> = vec![]; for (piece, node) in &pieces { let fits = |l: &Vec<(&Piece, &Node)>| { - l.first() - .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + l.first().is_some_and(|(f, n)| f.joins(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) }; match lines.iter_mut().find(|l| fits(l)) { Some(l) => l.push((piece, node)), @@ -698,13 +707,14 @@ fn join_text(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.joinText"; let d = &s.doc()?.doc; let pieces: Vec = target_texts(s, p, C)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect(); - // The piece top-left on the page sets the turn; the others turned like it are read in that frame. + // The piece top-left on the page sets the turn; the others turned like it, and as visible and as + // locked, are read in that frame. let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at; let first = pieces.iter().min_by(|x, y| { let (a, b) = (on_page(x), on_page(y)); a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)) }); - let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default(); + let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| f.joins(p)).collect()).unwrap_or_default(); kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x))); // Lines, top to bottom, by baseline; each line's pieces left to right. let mut lines: Vec> = vec![]; diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 6b03a26384..c9ed79122c 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -511,6 +511,64 @@ fn named_locked_and_hidden_type() { assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "the rotation is gone"); } +/// Combine Point Text joins named pieces only with pieces as visible and as locked, a group's lock +/// included: a visible piece and a hidden one beside it, or a free piece and one in a locked group, +/// stay as they are, with no undo step. +#[test] +fn combine_keeps_hidden_and_locked_pieces_apart() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + lock_or_hide(&mut s, b, false); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, b.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into())); + let d = &s.doc().unwrap().doc; + assert!(d.is_visible(a) && !d.is_visible(b), "the visible text stays shown and the hidden text stays hidden"); + + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + select(&mut s, &[b]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + lock_or_hide(&mut s, group, true); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.combineText", &json!({"ids": [a.0, group.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("Left".into(), "Right".into())); +} + +/// Join Point Text joins only the named pieces as visible and as locked as the top-left one: a +/// hidden piece between two visible lines stays as it is, hidden, and a hidden top-left piece +/// with only a visible one below it joins nothing, with no undo step. +#[test] +fn join_keeps_a_hidden_piece_apart() { + let mut s = session(); + let first = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20})); + let hidden = text(&mut s, json!({"x": 50, "y": 130, "text": "Hidden", "size": 20})); + let third = text(&mut s, json!({"x": 50, "y": 160, "text": "Third", "size": 20})); + lock_or_hide(&mut s, hidden, false); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.joinText", &json!({"ids": [first.0, hidden.0, third.0]})).unwrap(); + assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), undo_steps(&s)), (Some(first.0), Some(2), steps + 1)); + assert_eq!(obj(&s, first).plain_text(), "First\nThird"); + assert_eq!(obj(&s, hidden).plain_text(), "Hidden"); + let d = &s.doc().unwrap().doc; + assert!(d.is_visible(first) && !d.is_visible(hidden), "the visible text stays shown and the hidden text stays hidden"); + assert!(d.node(third).is_none()); + + let mut s = session(); + let top = text(&mut s, json!({"x": 50, "y": 100, "text": "Top", "size": 20})); + let below = text(&mut s, json!({"x": 50, "y": 130, "text": "Below", "size": 20})); + lock_or_hide(&mut s, top, false); + let steps = undo_steps(&s); + assert!(s.execute("addon.cleanup.joinText", &json!({"ids": [top.0, below.0]})).is_err()); + assert_eq!(undo_steps(&s), steps); + assert_eq!((obj(&s, top).plain_text(), obj(&s, below).plain_text()), ("Top".into(), "Below".into())); + assert!(s.doc().unwrap().doc.is_visible(below)); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/docs/mcp.md b/docs/mcp.md index 283990618f..7ce64ecca4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10711,8 +10711,10 @@ topmost piece. The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there is nothing for them to do. From the selection they take only type that can be edited; the type that `ids` names, or that is inside a group or layer it names, is taken even when it, its group or its layer is locked or hidden (not type -on a locked artboard). The objects they make from locked or hidden type are not selected. The pieces they make are -point type that keeps its characters' styles, each placed so the glyph of its first character lands where it was drawn. +on a locked artboard). The objects they make from locked or hidden type are not selected. Combine Point Text and Join +Point Text join a piece only with pieces that are as visible and as locked as it is, counting their groups and layers. +The pieces they make are point type that keeps its characters' styles, each placed so the glyph of its first character +lands where it was drawn. - `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one per line (`by: "lines"`, the default), paragraph (`"paragraphs"`), word (`"words"`) or glyph (`"glyphs"`; a ligature @@ -10724,14 +10726,15 @@ point type that keeps its characters' styles, each placed so the glyph of its fi staying where it was (within 0.01 pt). Pieces turned and sheared alike whose baselines lie within 0.4 em of each other join left to right (a baseline a little off becomes baseline shift); a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps its place. A gap wider than 10 em, an overlap of - more than 1 em, another look (paint, opacity, effects) or a piece that can't keep its place once joined (a ligature - forming across the join) starts another object. Each object made takes the place and paint of its leftmost piece. The - main use is type from a PDF that arrives one glyph per object. + more than 1 em, another look (paint, opacity, effects), another visibility or lock state, or a piece that can't keep + its place once joined (a ligature forming across the join) starts another object. Each object made takes the place + and paint of its leftmost piece. The main use is type from a PDF that arrives one glyph per object. - `addon.cleanup.joinText {ids?}` (Join Point Text) joins one-line horizontal point type into one point type object, - for text that arrives one line or fragment per object. Pieces turned and sheared like the top-left one are read as - lines (baselines within 0.4 em), top to bottom; a line's pieces join left to right with a space between them. Each - line becomes a left-aligned paragraph whose leading is the distance from the baseline above, so the lines stay where - they were. Character styles stay; the object takes the top-left piece's place and paint. Returns `{id, joined, lines}`. + for text that arrives one line or fragment per object. Pieces turned and sheared like the top-left one, and as visible + and as locked as it is, are read as lines (baselines within 0.4 em), top to bottom; a line's pieces join left to + right with a space between them. Each line becomes a left-aligned paragraph whose leading is the distance from the + baseline above, so the lines stay where they were. Character styles stay; the object takes the top-left piece's + place and paint. Returns `{id, joined, lines}`. - `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. After Select All in a frame of threaded text, it removes them from the text of every frame of the story, as one undo step.