From 3e0f9795c27b55478d3363b80492110ba1e8016f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 06:24:36 -0700 Subject: [PATCH] Threaded text: Create Outlines and Expand on a frame no longer duplicate its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create Outlines on a frame of threaded text removed the frame, and the thread's re-flow then added the frame's text to the story, as it does for a frame that was deleted. The other frames held that text a second time while the outlines drew it. Object › Expand, and Expand Appearance where it outlines type or groups it with a shadow, outer glow or Crop Marks, did the same. An outlined frame now leaves its thread with the text it shows, and the other frames keep the text they hold. When the thread's last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps a frame as type in a group, the type takes the frame's place in the thread. A thread left with one frame is no longer a thread, and undo restores the thread. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/effectcmd.rs | 6 +- crates/engine/src/cmd/threads.rs | 207 +++++++++++++++++++++++++++++ crates/engine/src/cmd/typecmd.rs | 5 +- docs/mcp.md | 8 ++ docs/target-app-parity.md | 1 + 5 files changed, 225 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index dc2e18054..33b73b2e5 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -692,9 +692,13 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let ids = s.edit("Expand Appearance", |d, _| { let brushes = vectorcraft_brush::library(d); let mut out = vec![]; + let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } + // Outlined frames of threaded text leave their threads with the text they show; type that + // stays type under a new id takes its frame's place. + super::threads::leave_threads(d, &threaded); if out.is_empty() { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index e1583e129..23e2bb1af 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -207,6 +207,78 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { out } +/// Every frame of `d`'s threads with its type, for [`leave_threads`] after an edit that may turn +/// some of them into other art. +pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> { + d.text_threads.iter().flatten().filter_map(|id| Some((*id, text_of(d, *id)?.clone()))).collect() +} + +/// Update the threads for each frame of `was` (from [`frames`] before the edit) that `d` no longer +/// holds as area type or type on a path under its id. When the art now at that id holds the frame's +/// type under a new id (Expand Appearance groups type with its shadows, outer glows or Crop Marks), +/// that type takes the frame's place in its thread. Otherwise (Create Outlines and Expand Appearance +/// made outlines of it) the frame leaves its thread with the text it shows, and the other frames keep +/// the text they hold. When a thread's last frame leaves, the text it has no room for goes after the +/// text of the last frame that stays. A thread left with one frame is no longer a thread. +pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) { + let gone: Vec<(NodeId, &TextObject, Option)> = + was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect(); + if gone.is_empty() { + return; + } + let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id); + for i in 0..d.text_threads.len() { + let thread = d.text_threads[i].clone(); + if !thread.iter().any(|id| find(id).is_some()) { + continue; + } + let keep: Vec = thread + .iter() + .filter_map(|id| match find(id) { + Some((_, _, kept)) => *kept, + None => Some(*id), + }) + .collect(); + if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) { + take_overflow(d, *stay, last); + } + d.text_threads[i] = keep; + } + d.text_threads.retain(|t| t.len() >= 2); +} + +/// The only area type or type on a path in the art at `id`. +fn kept_type(d: &Document, id: NodeId) -> Option { + let mut found = vec![]; + d.node(id)?.walk(&mut |n| { + if matches!(&n.kind, NodeKind::Text(t) if !matches!(t.kind, TextKind::Point)) { + found.push(n.id); + } + }); + match found[..] { + [one] => Some(one), + _ => None, + } +} + +/// Put the text that frame `from` holds beyond what it shows after the text of frame `to`. +fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { + let db = vectorcraft_text::FontDb::global(); + // `from`'s text split as if it flowed on into a second frame: the second part is what `from` + // has no room for. + let parts = vectorcraft_text::thread::distribute(db, &[from, from], &from.runs, &from.paragraph_styles()); + let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; + let mut tail = from.clone(); + tail.runs = rest.runs.clone(); + tail.set_paragraph_styles(rest.paras.clone()); + if let Some(NodeKind::Text(t)) = d.node_mut(to).map(|n| &mut n.kind) { + let head = (**t).clone(); + t.runs = vectorcraft_text::thread::story(&[&head, &tail]); + t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&[&head, &tail], &[])); + refresh_bounds(t); + } +} + /// A closed path converted to an empty area-type frame with `style`. fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option { let NodeKind::Path { path, .. } = &n.kind else { return None }; @@ -815,6 +887,141 @@ mod tests { assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); } + /// `story` threaded through three area type frames, of 150 by 60, 40 and `last` points, each + /// showing part of it. + fn three_frames(story: &str, last: f64) -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let frame = |s: &mut Session, x: f64, y: f64, h: f64, text: &str| { + new_id(s.execute("text.create", &json!({"x": x, "y": y, "text": text, "area": {"width": 150, "height": h}})).unwrap()) + }; + let a = frame(&mut s, 10.0, 10.0, 60.0, story); + let b = frame(&mut s, 10.0, 100.0, 40.0, ""); + let c = frame(&mut s, 200.0, 10.0, last, ""); + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread.add", &json!({"from": b, "to": c})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]]); + assert!([a, b, c].iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, [a, b, c]) + } + + /// Whether the document holds area type or type on a path `id`. + fn is_frame(s: &Session, id: u64) -> bool { + text_of(&s.doc().unwrap().doc, NodeId(id)).is_some() + } + + /// Create Outlines and Object › Expand on frames of threaded text take them out of their + /// thread with the text they show, which their outlines draw, and the other frames keep the + /// text they hold: each character is in the document once. A thread left with one frame is no + /// thread. Undo threads the frames again. + #[test] + fn outlined_frames_leave_their_thread_with_their_text() { + for command in ["type.createOutlines", "object.expand"] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute(command, &json!({})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{command} {out:?}: frame {id} is outlined"); + } + let st = s.doc().unwrap(); + let groups = st.selection.objects.iter().filter(|g| matches!(st.doc.node(**g).map(|n| &n.kind), Some(NodeKind::Group { .. }))); + assert_eq!(groups.count(), ids.len(), "{command} {out:?}: the outlines are selected"); + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{command} {out:?}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{command} {out:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{command} {out:?}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{command} {out:?}: undo"); + } + } + } + + /// Expand Appearance outlines a frame with an effect, and the frame leaves its thread with the + /// text it shows; Object › Expand of such a frame expands its appearance first. + #[test] + fn a_frame_whose_appearance_expands_leaves_its_thread_with_its_text() { + for command in ["effect.expandAppearance", "object.expand"] { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [b]})).unwrap(); + let shown = [a, b, c].map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert!(!is_frame(&s, b), "{command}: the frame is outlined"); + assert_eq!((text(&s, a), text(&s, c)), (shown[0].clone(), shown[2].clone()), "{command}: the other frames keep their text"); + assert_eq!(threads(&s), [[a, c]], "{command}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b, c]], "{command}: undo"); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown, "{command}: undo"); + } + } + + /// Expand Appearance keeps type whose only effect is a shadow or Crop Marks as type under a new + /// id, in a group under the frame's id: the type takes the frame's place in its thread, and + /// each frame keeps its text, the last frame the text it has no room for too. Type in a group + /// with Crop Marks keeps its id and stays in its thread. + #[test] + fn type_that_expand_appearance_keeps_takes_its_frames_place() { + for (effect, grouped) in [("stylize.dropShadow", false), ("cropMarks", false), ("cropMarks", true)] { + for k in [1, 2] { + let case = format!("{effect} grouped {grouped} frame {k}"); + let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0); + let id = frames[k]; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let target = if grouped { new_id(s.execute("object.group", &json!({})).unwrap()) } else { id }; + s.execute("effect.apply", &json!({"effect": effect, "item": null, "ids": [target]})).unwrap(); + let shown = frames.map(|id| text(&s, id)); + s.execute("select.set", &json!({"ids": [target]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let mut types = vec![]; + if let Some(n) = s.doc().unwrap().doc.node(NodeId(target)) { + n.walk(&mut |m| { + if matches!(m.kind, NodeKind::Text(_)) { + types.push(m.id.0); + } + }); + } + let [kept] = types[..] else { panic!("{case}: the type is kept: {types:?}") }; + assert_eq!(kept == id, grouped, "{case}: the type has a new id unless it was in a group"); + let mut now = frames; + now[k] = kept; + assert_eq!(threads(&s), [now], "{case}: the type takes the frame's place"); + assert_eq!(now.map(|id| text(&s, id)), shown, "{case}: each frame keeps its text"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + } + + /// The text that does not fit a thread's last frame, which its outlines do not draw, stays in + /// the story: when the last frame is outlined, the frame before it takes that text after its + /// own. + #[test] + fn outlining_the_last_frame_keeps_the_text_it_has_no_room_for() { + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([a, b, c].map(|id| text(&s, id)), shown); + } + /// A story of one-line paragraphs that each start with a quotation mark, threaded from area /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. fn quoted_thread() -> (Session, u64, u64) { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b8314b1dc..4aaabb602 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph) → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}", has_selection, create_outlines ), @@ -544,6 +544,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; + let threaded = super::threads::frames(d); for tid in texts { let Some(n) = d.node(tid).cloned() else { continue }; let NodeKind::Text(t) = &n.kind else { continue }; @@ -602,6 +603,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } + // Outlined frames of threaded text leave their threads with the text they show. + super::threads::leave_threads(d, &threaded); sel.set(new_sel); Ok(out) })?; diff --git a/docs/mcp.md b/docs/mcp.md index 17399747f..e96973648 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4145,6 +4145,14 @@ numbers and a `before` that isn't true or false. {"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}} ``` +`type.createOutlines` (Type › Create Outlines) and `effect.expandAppearance` (when it outlines type, as Expand +Appearance describes) take an outlined frame of threaded text out of its thread with the text it shows, and the other +frames keep the text they hold. When the thread's last frame is outlined, the text it has no room for, which its +outlines do not draw, goes after the text of the last frame that stays. A thread left with one frame is no longer a +thread. When `effect.expandAppearance` keeps a frame as type in a group (type with no fills or strokes of its own whose only +effects are shadows, outer glows or Crop Marks), the type stays in the thread in the frame's place. Object › Expand +(`object.expand`) outlines type with these two commands. + ## Converting between point type and area type With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ca2480de9..f5854175b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. + Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests_hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.