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:
parent
fa2d901a7e
commit
dabd179d5a
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue