From 7a9ae4324d4643992977966f41221e08924a48e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:25:13 -0700 Subject: [PATCH] Threaded text: Rasterize on a frame no longer duplicates its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Rasterize replaced a frame of threaded text by an image of it. The thread's re-flow then added the frame's text to the story, as it does for a frame that was deleted, so the other frames held that text a second time while the image drew it. A frame that Rasterize turns into an image now leaves its thread with the text it shows, and the other frames keep the text they hold, as with Create Outlines and Expand. When the thread's last frame is rasterized, 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, and undo restores the thread. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/menucmds.rs | 5 +++- crates/engine/src/cmd/threads.rs | 44 +++++++++++++++++++++++++++++++ docs/mcp.md | 4 ++- docs/target-app-parity.md | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c9ff3f455..904e75b4e 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -94,7 +94,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -561,8 +561,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing selected")); } let id = s.edit("Rasterize", |d, sel| { + let threaded = super::threads::frames(d); let node = image.into_node(d); let id = d.wrap(&roots, |_| node)?; + // Rasterized frames of threaded text leave their threads with the text they show. + super::threads::leave_threads(d, &threaded); sel.set([id]); Ok(id) })?; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 3df488404..409081079 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -1127,6 +1127,50 @@ mod tests { assert_eq!([a, b, c].map(|id| text(&s, id)), shown); } + /// Object › Rasterize on frames of threaded text takes them out of their thread with the text + /// they show, which the image draws, and the other frames keep the text they hold. Undo threads + /// the frames again. When the last frame is rasterized, the frame before it takes the text it + /// has no room for. + #[test] + fn rasterized_frames_leave_their_thread_with_their_text() { + for clip in [false, true] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("clippingMask {clip} {out:?}"); + 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("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + 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("object.rasterize", &json!({"ppi": 36})).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]]); + } + /// Frame `id`'s first line: whether its first paragraph continues from the frame before, where /// its text starts from the frame's left edge, and how many list markers it starts with. fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { diff --git a/docs/mcp.md b/docs/mcp.md index 9ee0e1388..49167e360 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3267,7 +3267,9 @@ images, symbol instances, live objects, and those in opacity masks, symbols and go in an image under the untouched vector art, other effects replace the object with its image, and a path whose fills or strokes carry effects is written as one piece per fill and stroke, so only the affected ones become images. When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image -without it, and their results carry the `warning` that Flatten Transparency gives. +without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame +of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see +Threading text from the in and out ports), and the other frames keep the text they hold. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 016e7a771..2d2f7c16b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -765,6 +765,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. 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. + Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. 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.