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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:28:04 -04:00
parent fa2d901a7e
commit dabd179d5a
3 changed files with 78 additions and 13 deletions

View File

@ -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 `<pattern>`/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 |

View File

@ -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::<Vec<_>>()
);
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);
}
}

View File

@ -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<Vec<(f64, f64)>> =
(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<usize>], 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<usize>], 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<usize>], 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<usize>], 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<usize>], 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 {