From b1d364033e20ebc7f02c3d1698330537df016d9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:45:50 -0800 Subject: [PATCH] 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.