Merge pull request #3977 from dotCooCoo/fix/outlines-of-threaded-frame-keep-text-once

Threaded text: Create Outlines and Expand on a frame no longer duplicate its text
This commit is contained in:
BFlat
2026-10-24 17:51:29 +07:00
committed by GitHub
5 changed files with 225 additions and 2 deletions
+5 -1
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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()));
}
+207
View File
@@ -207,6 +207,78 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
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<NodeId>)> =
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<NodeId> = 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<NodeId> {
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<Node> {
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<u64> = out.iter().map(|k| frames[*k]).collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute(command, &json!({})).unwrap();
let rest: Vec<u64> = 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) {
+4 -1
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
let mut new_sel: Vec<NodeId> = 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<Value> {
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)
})?;
+8
View File
@@ -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
+1
View File
@@ -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.