From dabd179d5a72f61e595b27f4466586aee16d4bba Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:28:04 -0400 Subject: [PATCH] M7.4: Text Wrap flows on both sides of an object (lines split into every free span, left to right, sharing the baseline; exact interval intersection across the line band) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +-- crates/engine/src/cmd/textwrap.rs | 28 +++++++++++++++ crates/text/src/layout.rs | 59 +++++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 90cbcc3e..36da2211 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ _Last updated: 2026-10-02._ | 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); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟑 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-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 | 15–25 | | 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) | 🟑 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert; follows edits), Type on a Path effects done; Tabs panel, spell check, vertical type, wrap on both sides of an object pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects done; Tabs panel, spell check, vertical type pending | 45–60 | | 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 incl. Effect β†’ Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | 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 | 23–37 | @@ -96,7 +96,7 @@ are counted bottom-up. | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| Type core | 9 | 75% | wrap on both sides of an object, composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | +| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | | Type advanced | 4 | 25% | vertical type/CJK, Tabs panel, spell check (open dictionary), Touch Type, Retype | 30–40 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 | diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index 76ca49b4..6da8b453 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -248,4 +248,32 @@ mod tests { s.execute("object.arrange.sendToBack", &json!({})).unwrap(); assert_eq!(lines(&s, t), plain); } + + #[test] + fn text_flows_on_both_sides_of_a_centred_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let t = s + .execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(300), "size": 12, "area": {"width": 400, "height": 400}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("shape.ellipse", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + let Some(NodeKind::Text(tx)) = s.doc().unwrap().doc.node(NodeId(t)).map(|n| n.kind.clone()) else { panic!() }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + // Beside the circle each line band holds two lines: left of it and right of it. + let beside: Vec<_> = lay.lines.iter().filter(|l| l.baseline > 120.0 && l.baseline < 180.0).collect(); + assert!( + beside.iter().any(|l| l.avail.1 < 150.0) && beside.iter().any(|l| l.avail.0 > 250.0), + "{:?}", + beside.iter().map(|l| l.avail).collect::>() + ); + let left = beside.iter().find(|l| l.avail.1 < 150.0).unwrap(); + assert!(beside.iter().any(|l| l.avail.0 > 250.0 && (l.baseline - left.baseline).abs() < 1e-9), "same baseline on both sides"); + // Reading order: the right-hand line follows the left-hand one. + let li = lay.lines.iter().position(|l| std::ptr::eq(l, *left)).unwrap(); + assert!(lay.lines[li + 1].avail.0 > 250.0); + } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 23e8e4bb..8e81ce60 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -255,13 +255,19 @@ impl Region { /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> { + self.spans(top, bottom).into_iter().max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) + } + + /// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to + /// right (text wraps on both sides of an object). + fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { let clip = |(a, b): (f64, f64)| { let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); (b > a).then_some((a, b)) }; let plain = self.polys.is_empty() || self.rect; if plain && self.wraps.is_empty() { - return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip); + return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip).into_iter().collect(); } let fb = if plain { self.cell @@ -274,6 +280,11 @@ impl Region { let mut rows: Vec> = (0..samples).map(|k| self.intervals(clamp(top + (bottom - top) * k as f64 / (samples - 1) as f64))).collect(); let mid = rows.swap_remove(samples / 2); + if !self.wraps.is_empty() { + // Wrap objects split lines: keep exactly what is free on every sampled row. + let free = rows.iter().fold(mid, |acc, r| intersect_intervals(&acc, r)); + return free.into_iter().filter_map(clip).collect(); + } mid.into_iter() .filter_map(|(mut a, mut b)| { for o in &rows { @@ -284,7 +295,7 @@ impl Region { } clip((a, b)) }) - .max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) + .collect() } } @@ -335,16 +346,23 @@ struct Pen<'r> { pending: f64, fb: FirstBaseline, fb_min: f64, + /// Further spans at the current baseline (text wrapping on both sides of an object). + queued: Vec<(f64, f64, f64)>, } impl Pen<'_> { /// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` = /// the frame is full (overflow). - fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64)> { + /// The `bool` is true for a further span at the previous line's baseline. + fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> { let Some(regions) = self.regions else { let b = self.prev.map_or(0.0, |b| b + est.lead + self.pending); - return Some((b, f64::NEG_INFINITY, f64::INFINITY)); + return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); }; + if !self.queued.is_empty() { + let (b, x0, x1) = self.queued.remove(0); + return Some((b, x0, x1, true)); + } loop { let r = regions.get(self.ri)?; let mut baseline = match self.prev { @@ -355,15 +373,29 @@ impl Pen<'_> { if baseline + est.desc > r.bottom() + 0.01 { break; } - match r.span(baseline - est.asc, baseline + est.desc) { - Some((a, b)) if b - a - ind_l - ind_r > est.asc.max(1.0) => return Some((baseline, a, b)), - _ => baseline += est.lead.max(1.0), + let fits = |&(a, b): &(f64, f64)| b - a - ind_l - ind_r > est.asc.max(1.0); + if r.wraps.is_empty() { + match r.span(baseline - est.asc, baseline + est.desc) { + Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)), + _ => baseline += est.lead.max(1.0), + } + } else { + // Wrap objects can split a line: fill every span, left to right. + let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect(); + if spans.is_empty() { + baseline += est.lead.max(1.0); + continue; + } + let (a, b) = spans.remove(0); + self.queued = spans.into_iter().map(|(a, b)| (baseline, a, b)).collect(); + return Some((baseline, a, b, false)); } } // Next row/column. self.ri += 1; self.prev = None; self.pending = 0.0; + self.queued.clear(); } } fn bottom(&self) -> f64 { @@ -502,7 +534,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> while widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 }; - let Some((b, x0, x1)) = sim.place(m, ind_l, para.right_indent) else { break }; + let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent) else { break }; let w = x1 - x0 - ind_l - para.right_indent; widths.push(w); acc += w.max(1.0); @@ -520,7 +552,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> } fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { - let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min }; + let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] }; 'paras: for (pi, pr) in paras.iter().enumerate() { let sg = cx.shape_para(pr.clone()); let pm = { @@ -543,7 +575,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt // next row/column if it no longer fits). let (baseline, x0, x1, end, hyph, m) = loop { let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1)) = pen.place(est, ind_l, para.right_indent) else { + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent) else { cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; break 'paras; }; @@ -554,7 +586,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt _ => (n, false), }; let m = Metrics::max(&sg[i..end]).unwrap_or(pm); - baseline += if pen.regions.is_none() && first_in_region { + baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 } else if first_in_region { m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) @@ -566,6 +598,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt pen.ri += 1; pen.prev = None; pen.pending = 0.0; + pen.queued.clear(); continue; } break (baseline, x0, x1, end, hyph, m); @@ -654,6 +687,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, }); pen.prev = Some(baseline); + // Further spans of this line band share the settled baseline. + for q in &mut pen.queued { + q.0 = baseline; + } li_para += 1; i = end; if i >= n {