diff --git a/ROADMAP.md b/ROADMAP.md index eab8720b..e15b3345 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ _Last updated: 2026-10-01._ | Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** | | Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** | | Feature surface vs full Illustrator | **~45–50%** | -| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~210–340 h** (sum of the milestone rows below) | +| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~200–330 h** (sum of the milestone rows below) | | Estimated time to **1:1 feel** (edge cases, modifier nuances, typographic/colour fidelity) | **~500+ h** | ### Shipped so far @@ -40,6 +40,7 @@ _Last updated: 2026-10-01._ - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather. +- **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. @@ -60,7 +61,7 @@ _Last updated: 2026-10-01._ | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟑 Export for Screens (PNG/JPG/WebP/SVG/PDF Γ— scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8–12 | | M5 | Performance | 🟑 background render + caches + MT done; `drawcraft-cli bench` and `drawcraft-cli perf` (budget suite); file format v2 opens 3Γ— faster (50k paths: 722 β†’ 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | βœ… mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 in progress | 20–30 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs done; OpenType/Tabs panels, text wrap, Find Font, spell check pending | 12–20 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟑 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟑 2D effects done; 3D pending | 25–40 | | M10 | Brushes, symbols, patterns, Repeat | 🟑 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 | @@ -70,7 +71,7 @@ _Last updated: 2026-10-01._ | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟑 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 | | β€” | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 40–60 | | β€” | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟑 | 30–50 | -| | **Total to checklist parity** (sum of the rows above) | | **~210–340** | +| | **Total to checklist parity** (sum of the rows above) | | **~200–330** | ## Out of scope (by design or by law) - **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8bf48df7..b202b777 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -274,6 +274,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub para_styles: Vec, + /// Threaded text: area-type frames one story flows through, in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub text_threads: Vec>, #[serde(default)] pub symbols: Vec, #[serde(default)] @@ -324,6 +327,7 @@ impl Document { graphic_styles: default_graphic_styles(), char_styles: vec![], para_styles: vec![], + text_threads: vec![], symbols: vec![], guides: vec![], grid: GridPrefs::default(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bf592c7b..106948f5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ mod recolor; mod select; mod textedit; pub mod textstyles; +pub(crate) mod threads; mod typecmd; mod typemenu; pub mod wand; @@ -153,6 +154,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(typemenu::specs()); v.extend(textedit::specs()); v.extend(textstyles::specs()); + v.extend(threads::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs new file mode 100644 index 00000000..29b3fb8d --- /dev/null +++ b/crates/engine/src/cmd/threads.rs @@ -0,0 +1,281 @@ +//! Type β†’ Threaded Text: one story flowing through area-type frames in order. +//! +//! Frames are ordinary area text objects holding their slice of the story; the document lists the +//! thread order. After every edit, threads whose frames changed are re-flowed ([`reflow`]). + +use drawcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "text.thread.create", + "Create", + ["Type", "Threaded Text"], + None, + "{} thread the selected area type (and closed paths, which become frames) in stacking order β†’ {ids}", + has_multi, + create + ), + cmd!( + "text.thread.releaseSelection", + "Release Selection", + ["Type", "Threaded Text"], + None, + "{} take the selected frames out of their threads (the story re-flows through the rest)", + has_thread_selection, + release + ), + cmd!( + "text.thread.remove", + "Remove Threading", + ["Type", "Threaded Text"], + None, + "{} break the selected frames' threads; each frame keeps the text it shows", + has_thread_selection, + remove + ), + cmd!(query "text.thread.info", "Threads", [], None, "{} β†’ [[id…]] every thread in order", has_doc, |s, _| { + Ok(json!(s.doc()?.doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect::>()).collect::>())) + }), + ] +} + +fn has_thread_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().unwrap(); + if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) { + Ok(()) + } else { + Err("no threaded text selected".into()) + } +} + +fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }) => Some(t), + _ => None, + } +} + +/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing +/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones. +pub(crate) fn reflow(before: &Document, doc: &mut Document) { + let threads = std::mem::take(&mut doc.text_threads); + let mut kept = vec![]; + for thread in threads { + let changed = thread.iter().any(|id| match (before.node(*id), doc.node(*id)) { + (Some(a), Some(b)) => !std::ptr::eq(a, b), + (None, None) => false, + _ => true, + }); + // The story: live frames' runs, plus the text of frames deleted by this edit. + let mut story: Vec = vec![]; + let mut live = vec![]; + for id in &thread { + match (text_of(doc, *id), text_of(before, *id)) { + (Some(t), _) => { + story.extend(t.runs.iter().cloned()); + live.push(*id); + } + (None, Some(old)) => story.extend(old.runs.iter().cloned()), + (None, None) => {} + } + } + if !changed || live.is_empty() { + if live.len() >= 2 { + kept.push(live); + } + continue; + } + drawcraft_text::edit::normalize(&mut story); + let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); + let refs: Vec<&TextObject> = frames.iter().collect(); + let parts = drawcraft_text::thread::distribute(drawcraft_text::FontDb::global(), &refs, &story); + for (id, runs) in live.iter().zip(parts) { + if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) + && t.runs != runs + { + t.runs = runs; + refresh_bounds(t); + } + } + if live.len() >= 2 { + kept.push(live); + } + } + doc.text_threads = kept; +} + +/// A closed path converted to an empty area-type frame with `style`. +fn frame_from_path(n: &Node, style: drawcraft_doc::CharStyle) -> Option { + let NodeKind::Path { path, .. } = &n.kind else { return None }; + if !path.subpaths.first().is_some_and(|s| s.closed) { + return None; + } + let mut t = TextObject::point(drawcraft_geom::Point::ZERO, "", style); + t.kind = TextKind::Area { frame: path.clone() }; + t.xf = drawcraft_geom::Affine::IDENTITY; + refresh_bounds(&mut t); + Some(Node::new(n.id, NodeKind::Text(Box::new(t)))) +} + +fn create(s: &mut Session, _: &Value) -> Result { + const C: &str = "text.thread.create"; + let ids = selected_roots(s)?; + let d = &s.doc()?.doc; + let style = ids.iter().find_map(|i| text_of(d, *i)).map(|t| t.first_style()).unwrap_or_default(); + let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone()).is_some())); + if ids.len() < 2 || !ok || !ids.iter().any(|i| text_of(d, *i).is_some()) { + return Err(bad(C, "select area type and closed paths (at least one area type object)")); + } + let out = ids.clone(); + s.edit("Thread Text", |d, _| { + for id in &ids { + if text_of(d, *id).is_none() { + let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; + let f = frame_from_path(&n, style.clone()).ok_or_else(|| bad(C, "not a closed path"))?; + *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f; + } + } + // Existing threads containing any of these frames merge into the new one, keeping order. + let mut order: Vec = vec![]; + for id in &ids { + match d.text_threads.iter().position(|t| t.contains(id)) { + Some(i) => { + for x in d.text_threads.remove(i) { + if !order.contains(&x) { + order.push(x); + } + } + } + None if !order.contains(id) => order.push(*id), + None => {} + } + } + d.text_threads.push(order); + // Force a re-flow of the new thread on this edit. + touch(d, ids[0]); + Ok(()) + })?; + Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>() })) +} + +/// Give a node a fresh allocation so [`reflow`] sees its thread as changed. +fn touch(d: &mut Document, id: NodeId) { + if let Some(n) = d.node_mut(id) { + let copy = n.clone(); + *n = copy; + } +} + +fn release(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Release Threaded Text", |d, _| { + // The released frames' text joins the rest of the story (they become empty). + for thread in d.text_threads.clone() { + let (out, keep): (Vec, Vec) = thread.iter().partition(|i| ids.contains(i)); + if out.is_empty() { + continue; + } + let mut story: Vec = vec![]; + for id in &thread { + if let Some(t) = text_of(d, *id) { + story.extend(t.runs.iter().cloned()); + } + } + let style = story.first().map(|r| r.style.clone()).unwrap_or_default(); + for id in &out { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + t.runs = vec![TextRun { text: String::new(), style: style.clone() }]; + refresh_bounds(t); + } + } + if let Some(first) = keep.first() + && let Some(NodeKind::Text(t)) = d.node_mut(*first).map(|n| &mut n.kind) + { + drawcraft_text::edit::normalize(&mut story); + t.runs = story; + } + let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0); + d.text_threads[i] = keep; + } + d.text_threads.retain(|t| t.len() >= 2); + Ok(()) + })?; + ok() +} + +fn remove(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + s.edit("Remove Threading", |d, _| { + d.text_threads.retain(|t| !t.iter().any(|i| ids.contains(i))); + Ok(()) + })?; + ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEXT: &str = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. "; + + fn setup() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": TEXT.repeat(4), "area": {"width": 150, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + (s, a, b) + } + + fn text(s: &Session, id: u64) -> String { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } + } + + #[test] + fn thread_flows_reflows_on_edit_and_survives_delete() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let (ta, tb) = (text(&s, a), text(&s, b)); + assert!(!ta.is_empty() && !tb.is_empty()); + assert_eq!(format!("{ta}{tb}"), TEXT.repeat(4)); + // Editing the first frame re-flows into the second. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "NEW "})).unwrap(); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), format!("NEW {}", TEXT.repeat(4))); + assert_ne!(text(&s, b), tb, "the overflow moved on"); + // Deleting the first frame moves all its text into the second. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(text(&s, b), format!("NEW {}", TEXT.repeat(4))); + assert!(s.doc().unwrap().doc.text_threads.is_empty(), "a one-frame thread is no thread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.text_threads.len(), 1); + } + + #[test] + fn release_and_remove() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let tb = text(&s, b); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(text(&s, b), tb, "remove keeps what each frame shows"); + assert!(s.doc().unwrap().doc.text_threads.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(text(&s, b), ""); + assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame"); + } +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 882802a1..749b29ee 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -40,6 +40,15 @@ pub fn specs() -> Vec { has_selection, area_options ), + cmd!( + "text.fitHeadline", + "Fit Headline", + ["Type"], + None, + "{ids?} track the first line of area type so it fills the frame width β†’ {tracking}", + has_selection, + fit_headline + ), cmd!( "text.setStyle", "Character", @@ -295,3 +304,75 @@ mod area_tests { assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); } } + +/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. +fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { + let mut probe = t.clone(); + let para_end = probe.plain_text().find('\n').unwrap_or(usize::MAX); + drawcraft_text::edit::style_range(&mut probe.runs, 0, para_end.min(drawcraft_text::edit::runs_len(&t.runs)), |st| st.tracking = tracking); + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &probe); + let cell = lay.frames.first()?; + let line = lay.lines.first()?; + let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent; + Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count())) +} + +fn fit_headline(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.fitHeadline"; + let ids = text_targets(s, p, C)?; + let d = &s.doc()?.doc; + let mut plans = vec![]; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node(*id).map(|n| &n.kind) else { continue }; + if !matches!(t.kind, drawcraft_doc::TextKind::Area { .. }) { + continue; + } + // Largest tracking that keeps the first paragraph on one line (bisection). + let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, lines)| lines == 1); + let (mut lo, mut hi) = (-200.0, 2000.0); + if !one_line(lo) { + continue; + } + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if one_line(mid) { lo = mid } else { hi = mid } + } + plans.push((*id, (lo * 10.0).floor() / 10.0)); + } + let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?; + s.edit("Fit Headline", |d, _| { + for (id, tr) in &plans { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(drawcraft_text::edit::runs_len(&t.runs)); + drawcraft_text::edit::style_range(&mut t.runs, 0, end, |st| st.tracking = *tr); + refresh_bounds(t); + } + } + Ok(()) + })?; + Ok(json!({ "tracking": first })) +} + +#[cfg(test)] +mod headline_tests { + use super::*; + + #[test] + fn fit_headline_fills_the_first_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "text": "HEADLINE\nbody text", "size": 24, "area": {"width": 300, "height": 120}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap(); + assert!(tr > 100.0, "a short word spreads out: {tr}"); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; + let (w, avail, lines) = headline_fit(t, tr).unwrap(); + assert_eq!(lines, 1); + assert!(avail - w < 30.0, "{w} of {avail}"); + assert_eq!(t.runs.last().unwrap().style.tracking, 0.0, "the body keeps its tracking"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b2e17596..eacbdf70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -547,13 +547,20 @@ impl Session { let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); - let result = f(doc, &mut st.selection).and_then(|v| { - if doc_sane(&st.doc, &st.selection) { - Ok(v) - } else { - Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) + let result = match f(doc, &mut st.selection) { + Ok(v) => { + // Threaded text re-flows when any of its frames changed. + if !st.doc.text_threads.is_empty() { + cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); + } + if doc_sane(&st.doc, &st.selection) { + Ok(v) + } else { + Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) + } } - }); + Err(e) => Err(e), + }; match result { Ok(v) => { st.selection.prune(&st.doc); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 5463a80f..dc841125 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,6 +16,7 @@ mod fontdb; pub mod hyphen; mod layout; mod shape; +pub mod thread; pub use drawcraft_doc::TextObject; pub use features::OtFeatures; diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs new file mode 100644 index 00000000..0dafa112 --- /dev/null +++ b/crates/text/src/thread.rs @@ -0,0 +1,102 @@ +//! Threaded text: one story flowing through several area-type frames in order. +//! +//! Each frame keeps its own slice of the story as ordinary runs, so rendering, export and hit +//! testing need nothing special; after an edit the engine joins the slices and re-distributes them +//! with [`distribute`]. + +use drawcraft_doc::{TextObject, TextRun}; + +use crate::edit::{normalize, runs_len, slice_runs, style_at}; +use crate::{FontDb, layout}; + +/// The story: every frame's runs joined in thread order. +pub fn story(frames: &[&TextObject]) -> Vec { + let mut runs: Vec = frames.iter().flat_map(|f| f.runs.iter().cloned()).collect(); + normalize(&mut runs); + runs +} + +/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph +/// break stays with the line that ends there). +fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { + let mut probe = frame.clone(); + probe.runs = runs.to_vec(); + let lay = layout(db, &probe); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + if !lay.overflow { + return text.len(); + } + let mut end = lay.lines.last().map_or(0, |l| l.end); + let bytes = text.as_bytes(); + while end < bytes.len() && bytes[end] == b' ' { + end += 1; + } + if end < bytes.len() && bytes[end] == b'\n' { + end += 1; + } + end +} + +/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty +/// slices keep one empty run carrying the style at the split, so typing there has a style. +pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec> { + let mut rest = runs.to_vec(); + let mut out = Vec::with_capacity(frames.len()); + for (i, f) in frames.iter().enumerate() { + let len = runs_len(&rest); + let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) }; + let mut head = slice_runs(&rest, 0, n); + if head.is_empty() { + head.push(TextRun { text: String::new(), style: style_at(&rest, n) }); + } + let tail = slice_runs(&rest, n, len); + rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail }; + out.push(head); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use drawcraft_doc::{CharStyle, TextKind}; + use drawcraft_geom::{Affine, Rect, shapes}; + + fn frame(x: f64) -> TextObject { + let mut t = TextObject::point(drawcraft_geom::Point::ZERO, "", CharStyle::default()); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + 120.0, 60.0)) }; + t.xf = Affine::IDENTITY; + t + } + + #[test] + fn story_flows_across_frames_and_rejoins() { + let db = FontDb::global(); + let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0)); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here."; + let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }]; + let parts = distribute(db, &[&a, &b, &c], &story); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + // Each non-last slice fits its frame. + for (f, p) in [&a, &b].iter().zip(&parts) { + let mut t = (*f).clone(); + t.runs = p.clone(); + assert!(!layout(db, &t).overflow); + } + // Lines never start with the break space. + assert!(!parts[1][0].text.starts_with(' ')); + } + + #[test] + fn short_story_leaves_later_frames_empty_with_a_style() { + let db = FontDb::global(); + let (a, b) = (frame(0.0), frame(200.0)); + let st = CharStyle { size: 20.0, ..Default::default() }; + let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]); + assert_eq!(parts[0][0].text, "Hi"); + assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0)); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f54c8203..4e08a60d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -818,8 +818,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})), ], ), - sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]), - todo("Fit Headline"), + sub( + "Threaded Text", + vec![ + c("Create", "text.thread.create"), + c("Release Selection", "text.thread.releaseSelection"), + c("Remove Threading", "text.thread.remove"), + ], + ), + c("Fit Headline", "text.fitHeadline"), Sep, todo("Find Font…"), sub(