From 487da4b52a6f2f8a2b978c3b0c5a0eb4d05d11f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:02:05 -0800 Subject: [PATCH 1/4] Type: area type lines stay inside a frame that pinches in within their band A line in area type of a shape other than a rectangle was measured at five heights of its band, so an edge that pinched in between two of them (a notch tip at y = 47.5 in a 40..60 band) left the line running outside the frame. The frame's intervals over a band are now the intervals at its middle minus the x range of every edge within the band, which is exact for any number of vertices, holes and crossing contours. Edges that contours meet along do not shorten a line: horizontal ones count by coverage, and collinear ones by overlap, and an edge that only touches the top or bottom of the band is left out. Rectangles keep their fast path. Text wrap shapes were sampled at the same five heights, so a wrap object whose tip reached into a band between two of them let the line run across it. A line now keeps clear of every x at which a wrap shape, grown by its offset, lies anywhere in the band: the shape's intervals at the middle of the band plus every edge within it. With Invert Wrap, the band is cut into pieces a quarter of the offset tall (64 at most), and a line keeps to the x within the offset of the shape at the heights each piece's offset window holds, so it stays inside the grown shape over the whole band; an offset of 0 takes the shape's intervals over the whole band. A frame less than 0.0002 pt tall no longer panics when a line is measured in it. --- crates/text/src/layout/region.rs | 423 ++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 389 insertions(+), 35 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 9303d4c0fb..f0bdc315c3 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -86,14 +86,13 @@ fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { out } -/// `a` minus `b` (both unions of disjoint intervals). +/// `a` minus `b` (both unions of disjoint intervals, `b` sorted as [`union_intervals`] gives it). fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { let mut out = vec![]; for &(mut s, e) in a { - for &(c, d) in b { - if d <= s || c >= e { - continue; - } + // Only the intervals of `b` from the first that ends after `s` to the last that starts + // before `e` cut into this one. + for &(c, d) in b[b.partition_point(|&(_, d)| d <= s)..].iter().take_while(|&&(c, _)| c < e) { if c > s { out.push((s, c)); } @@ -120,6 +119,164 @@ fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { out } +/// The most pairs of edges [`slant_boundaries`] compares for one band. +const MAX_EDGE_PAIRS: usize = 1 << 14; + +/// An edge of a frame that isn't horizontal, within a band: its lower and upper ends, its y range +/// within the band and its x range there. +type Slant = (Point, Point, (f64, f64), (f64, f64)); + +/// The x of the line through `p` and `q` (not horizontal) at `y`. +fn line_x(p: Point, q: Point, y: f64) -> f64 { + p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x) +} + +/// The x ranges within a band at which the edges `slants` bound the frame. Edges that lie along one +/// line and overlap there (contours that meet along an edge, however each one divides it) count by +/// parity: a stretch of the line that an even number of them cover has the frame on both sides. +/// At most [`MAX_EDGE_PAIRS`] pairs are compared; edges left unpaired still count as boundaries, +/// which can make a line shorter at a seam but never puts it outside the frame. The flag is false +/// when the limit left edges unpaired. +fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool) { + fn root(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + // Edges along one line overlap in x, so only edges whose x ranges overlap are compared. + slants.sort_by(|u, v| u.3.0.total_cmp(&v.3.0)); + let mut parent: Vec = (0..slants.len()).collect(); + let mut pairs = 0; + 'edges: for (i, &(p, q, _, (_, x1))) in slants.iter().enumerate() { + // Ends on the line up to rounding (a few units in the last place of the coordinates) + // count as on it. + let scale = p.x.abs().max(p.y.abs()).max(q.x.abs()).max(q.y.abs()).max(1.0); + let (d, tol) = (q - p, 4.0 * f64::EPSILON * scale); + let on_line = |r: Point| d.cross(r - p).abs() <= tol * d.hypot(); + for (j, &(r, s, ..)) in slants.iter().enumerate().skip(i + 1).take_while(|(_, o)| o.3.0 <= x1 + tol) { + pairs += 1; + if pairs > MAX_EDGE_PAIRS { + break 'edges; + } + if on_line(r) && on_line(s) { + let (a, b) = (root(&mut parent, i), root(&mut parent, j)); + parent[a] = b; + } + } + } + let mut groups: Vec<(usize, usize)> = (0..slants.len()).map(|i| (root(&mut parent, i), i)).collect(); + groups.sort_unstable(); + let mut out = Vec::new(); + for run in groups.chunk_by(|a, b| a.0 == b.0) { + if let [(_, i)] = run { + out.push(slants[*i].3); + continue; + } + // The stretches an odd number of the edges cover: pairs of their sorted y ends. + let (p, q, ..) = slants[run[0].1]; + let mut ys: Vec = run.iter().flat_map(|&(_, i)| [slants[i].2.0, slants[i].2.1]).collect(); + ys.sort_by(f64::total_cmp); + for c in ys.as_chunks::<2>().0.iter().filter(|c| c[1] > c[0]) { + let (s, t) = (line_x(p, q, c[0]), line_x(p, q, c[1])); + out.push((s.min(t), s.max(t))); + } + } + (out, pairs <= MAX_EDGE_PAIRS) +} + +/// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its +/// offset. +const INVERT_WRAP_PIECES: f64 = 64.0; + +/// The edges of `polys` that reach into the band `top..bottom` (`top <= bottom`), leaving out those +/// that only touch its top or bottom: the horizontal ones as (y, x0, x1) and the others as +/// [`Slant`]s. +fn band_edges(polys: &[Vec], top: f64, bottom: f64) -> (Vec<(f64, f64, f64)>, Vec) { + let mut flats: Vec<(f64, f64, f64)> = Vec::new(); + let mut slants: Vec = Vec::new(); + for poly in polys { + for (i, &a) in poly.iter().enumerate() { + let b = poly[(i + 1) % poly.len()]; + let (lo, hi) = (a.y.min(b.y), a.y.max(b.y)); + if hi <= top || lo >= bottom { + continue; + } + if a.y == b.y { + flats.push((a.y, a.x.min(b.x), a.x.max(b.x))); + } else { + let (p, q) = if a.y < b.y { (a, b) } else { (b, a) }; + let ys = (lo.max(top), hi.min(bottom)); + let (s, t) = (line_x(p, q, ys.0), line_x(p, q, ys.1)); + slants.push((p, q, ys, (s.min(t), s.max(t)))); + } + } + } + (flats, slants) +} + +/// Inside intervals (even-odd) of `polys` over the whole band `top..bottom`: the intervals at the +/// middle of the band minus every x at which an edge lies within the band. A vertical line at any +/// other x meets no edge inside the band, so it is inside over the whole band exactly when it is +/// inside at the middle. An edge that only touches the top or bottom of the band leaves the band's +/// width alone, and so does a stretch of an edge that contours meet along (one that an even number +/// of edges cover). +fn band_intervals(polys: &[Vec], top: f64, bottom: f64) -> Vec<(f64, f64)> { + let (top, bottom) = (top.min(bottom), top.max(bottom)); + // Contours that meet along an edge cross the middle of the band at the same x, up to a few + // units of rounding: the intervals on either side of it join (an edge that bounds the + // shape there is still subtracted below). + let mut mid = poly_intervals(polys, (top + bottom) * 0.5); + mid.dedup_by(|next, prev| { + let seam = next.0 - prev.1 <= 8.0 * f64::EPSILON * prev.1.abs().max(next.0.abs()).max(1.0); + if seam { + prev.1 = prev.1.max(next.1); + } + seam + }); + subtract_intervals(&mid, &band_boundaries(polys, top, bottom).0) +} + +/// The x at which `polys` (even-odd) is inside somewhere in the band `top..bottom`, sorted and +/// disjoint: the intervals at the middle of the band and every x at which an edge lies within the +/// band. A vertical line at any other x meets no edge inside the band, so it is inside somewhere in +/// the band exactly when it is inside at the middle. Edges that contours meet along, which bound no +/// part of the shape, are left out as in [`band_intervals`]. When [`MAX_EDGE_PAIRS`] leaves edges +/// unpaired, `admit` keeps only the intervals at the middle, which the shape holds; otherwise every +/// edge counts, which can only widen the result. +fn band_cover(polys: &[Vec], top: f64, bottom: f64, admit: bool) -> Vec<(f64, f64)> { + let (top, bottom) = (top.min(bottom), top.max(bottom)); + let mut iv = poly_intervals(polys, (top + bottom) * 0.5); + let (edges, paired) = band_boundaries(polys, top, bottom); + if paired || !admit { + iv.extend(edges); + } + iv.retain(|e| e.1 > e.0); + union_intervals(iv) +} + +/// Every x at which an edge of `polys` bounds the shape within the band `top..bottom` +/// (`top <= bottom`), sorted and disjoint, leaving out edges that only touch the top or bottom of +/// the band, stretches of edges that an even number of edges cover, and vertical edges. The flag is +/// false when [`MAX_EDGE_PAIRS`] left edges unpaired, which can only add to the result. +fn band_boundaries(polys: &[Vec], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool) { + let (mut flats, mut slants) = band_edges(polys, top, bottom); + let (mut edges, paired) = slant_boundaries(&mut slants); + // The x covered by an odd number of the horizontal edges at one height: pairs of their + // sorted ends. + flats.sort_by(|p, q| p.0.total_cmp(&q.0)); + for run in flats.chunk_by(|p, q| p.0 == q.0) { + let mut xs: Vec = run.iter().flat_map(|&(_, a, b)| [a, b]).collect(); + xs.sort_by(f64::total_cmp); + edges.extend(xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1]))); + } + // A vertical edge covers a single x: where it bounds the shape, the band's middle already + // ends at it or an edge of some width meets it, so it is left out (vertical seams join). + edges.retain(|e| e.1 > e.0); + (union_intervals(edges), paired) +} + impl Region { pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { // A frame out of range has no cells, and a wrap shape out of range is left out. @@ -166,14 +323,38 @@ impl Region { self.cell.y1 - self.inset } - /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or - /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. - fn intervals(&self, y: f64) -> Vec<(f64, f64)> { - let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; + /// `iv` minus every x at which a wrap object lies somewhere in the band `top..bottom` (or the x + /// inside it over the whole band, for Invert Wrap). Offsets grow each wrap shape vertically + /// and horizontally. + fn wrapped(&self, mut iv: Vec<(f64, f64)>, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let (top, bottom) = (top.min(bottom), top.max(bottom)); for (polys, off, invert) in self.wraps.iter() { - let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; - let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); - iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; + if iv.is_empty() { + break; + } + let grow = |v: Vec<(f64, f64)>| union_intervals(v.into_iter().map(|(a, b)| (a - off, b + off)).collect()); + iv = if *invert { + // The band is cut into pieces. Each height of a piece `y0..y1` is within the offset + // of some height from `y0` plus the offset to `y1` less the offset, so an x the + // shape holds at all of those heights is inside the grown shape over the piece. In + // a piece less than twice the offset tall, each height of the piece is within the + // offset of every height from `y1` less the offset to `y0` plus the offset, so an x + // the shape holds at any of them is. Text within the offset of such an x, for every + // piece, stays inside the grown shape; finer pieces give it more of that room. + let pieces = if *off > 0.0 { ((bottom - top) * 4.0 / off).ceil().clamp(1.0, INVERT_WRAP_PIECES) } else { 1.0 }; + for k in 0..pieces as usize { + let at = |k: usize| top + (bottom - top) * k as f64 / pieces; + let (a, b) = (at(k) + off, at(k + 1) - off); + let held = if a <= b { band_intervals(polys, a, b) } else { band_cover(polys, b, a, true) }; + iv = intersect_intervals(&iv, &grow(held)); + if iv.is_empty() { + break; + } + } + iv + } else { + subtract_intervals(&iv, &grow(band_cover(polys, top - off, bottom + off, false))) + }; } iv } @@ -194,33 +375,19 @@ impl Region { if plain && self.wraps.is_empty() { return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip).into_iter().collect(); } - let fb = if plain { + let fb = if top.is_nan() || bottom.is_nan() { + return vec![]; + } else if plain { self.cell } else { self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)) }; - let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) }; - // Sample the band densely enough for curved frames (circles, blobs). - let samples = 5; - 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 { - let best = - o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?; - a = best.0; - b = best.1; - } - clip((a, b)) - }) - .collect() + // `max` then `min` rather than `clamp`, which panics on a frame less than 2e-4 tall. + let clamp = |y: f64| if plain { y } else { y.max(fb.y0 + 1e-4).min(fb.y1 - 1e-4) }; + let free = if plain { vec![(self.cell.x0, self.cell.x1)] } else { band_intervals(&self.polys, clamp(top), clamp(bottom)) }; + // Wrap objects split lines: keep what is free of them over the whole band. + let free = if self.wraps.is_empty() { free } else { self.wrapped(free, clamp(top), clamp(bottom)) }; + free.into_iter().filter_map(clip).collect() } } @@ -238,3 +405,189 @@ fn the_cells_of_a_frame_share_its_polygons_and_the_wrap_shapes() { assert!(!first.polys.is_empty() && !first.wraps.is_empty()); assert!(cells.iter().all(|c| c.polys.as_ptr() == first.polys.as_ptr() && c.wraps.as_ptr() == first.wraps.as_ptr())); } + +#[cfg(test)] +fn polygon_path(contours: &[&[(f64, f64)]]) -> BezPath { + let mut path = BezPath::new(); + for c in contours { + path.move_to(c[0]); + for &p in &c[1..] { + path.line_to(p); + } + path.close_path(); + } + path +} + +#[cfg(test)] +fn assert_spans(got: &[(f64, f64)], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?} != {want:?}"); + assert!(got.iter().zip(want).all(|(g, w)| (g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9), "{got:?} != {want:?}"); +} + +/// A line stays inside a frame whose right edge pinches in to a point between any fixed heights of +/// the line's band, with or without text wrap shapes elsewhere. +#[cfg(test)] +#[test] +fn a_line_stays_inside_a_frame_that_pinches_in_within_its_band() { + let notch = |tip: (f64, f64), (y0, y1): (f64, f64)| { + polygon_path(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, y0), tip, (100.0, y1), (100.0, 100.0), (0.0, 100.0)]]) + }; + let far = PathData::from_bezpath(&Rect::new(300.0, 0.0, 310.0, 100.0).to_path(0.1)); + let far = vectorcraft_doc::WrapShape { path: far, wrap: Default::default() }; + for (frame, x) in + [(notch((60.0, 47.5), (45.0, 50.0)), 60.0), (notch((70.0, 42.0), (41.0, 43.0)), 70.0), (notch((80.0, 59.0), (58.0, 60.0)), 80.0)] + { + for wrap in [&[][..], std::slice::from_ref(&far)] { + let cells = Region::cells(&frame, &LayoutOptions::default(), wrap); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, x)]); + assert_eq!(cells[0].span(40.0, 60.0), Some((0.0, x))); + } + } +} + +/// A line stays clear of a wrap object whose tip reaches into its band between any fixed heights of +/// the band, with or without an offset, and inside an Invert Wrap object whose edge pinches in +/// there. Invert Wrap with an offset keeps the lines it kept beside a straight edge. +#[cfg(test)] +#[test] +fn a_line_stays_clear_of_a_wrap_object_that_reaches_into_its_band() { + let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let spans = |shape: &[(f64, f64)], offset: f64, invert: bool| { + let path = PathData::from_bezpath(&polygon_path(&[shape])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert } }; + let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); + assert!(cells[0].rect); + cells[0].spans(40.0, 60.0) + }; + assert_spans(&spans(&[(100.0, 45.0), (40.0, 47.5), (100.0, 50.0)], 0.0, false), &[(0.0, 40.0)]); + assert_spans(&spans(&[(100.0, 47.0), (40.0, 47.5), (100.0, 48.0)], 1.0, false), &[(0.0, 39.0)]); + // Two contours that cancel each other (even-odd) hold no area and leave the line whole. + let band: &[(f64, f64)] = &[(0.0, 45.0), (100.0, 45.0), (100.0, 55.0), (0.0, 55.0)]; + let path = PathData::from_bezpath(&polygon_path(&[band, band])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert: false } }; + assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.0), (60.0, 47.5), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&spans(¬ch, 0.0, true), &[(0.0, 60.0)]); + let triangle = [(0.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&spans(&triangle, 5.0, true), &[(0.0, 50.0)]); + assert_spans(&spans(&triangle, 15.0, true), &[(0.0, 70.0)]); + // An Invert Wrap shape that steps sideways within the band keeps the room its offset bridges. + let upper: &[(f64, f64)] = &[(0.0, 0.0), (40.0, 0.0), (40.0, 50.0), (0.0, 50.0)]; + let lower: &[(f64, f64)] = &[(45.0, 50.0), (85.0, 50.0), (85.0, 100.0), (45.0, 100.0)]; + let path = PathData::from_bezpath(&polygon_path(&[upper, lower])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; + assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0), &[(40.0, 45.0)]); +} + +/// A hole that starts and ends inside a line's band splits the line, and a band below the hole, or +/// one that only touches it, keeps the whole width. +#[cfg(test)] +#[test] +fn a_hole_inside_a_band_splits_the_line() { + let frame = polygon_path(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], &[(40.0, 52.0), (60.0, 52.0), (60.0, 58.0), (40.0, 58.0)]]); + let cells = Region::cells(&frame, &LayoutOptions::default(), &[]); + assert!(!cells[0].rect); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 40.0), (60.0, 100.0)]); + assert_spans(&cells[0].spans(60.0, 80.0), &[(0.0, 100.0)]); + // A band that only touches the hole at its top or bottom keeps the whole width. + assert_spans(&cells[0].spans(58.0, 78.0), &[(0.0, 100.0)]); + assert_spans(&cells[0].spans(32.0, 52.0), &[(0.0, 100.0)]); + // A band given bottom first covers the same heights. + assert_spans(&cells[0].spans(60.0, 40.0), &[(0.0, 40.0), (60.0, 100.0)]); + assert!(cells[0].spans(f64::NAN, 60.0).is_empty()); +} + +/// Lines run across an edge two contours share (a horizontal one over the part both cover) and +/// stop at the part only one covers (a step). +#[cfg(test)] +#[test] +fn lines_cross_an_edge_two_contours_share() { + let seam = polygon_path(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], &[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)]]); + let cells = Region::cells(&seam, &LayoutOptions::default(), &[]); + assert!(!cells[0].rect); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + let step = polygon_path(&[&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], &[(0.0, 50.0), (50.0, 50.0), (50.0, 100.0), (0.0, 100.0)]]); + let cells = Region::cells(&step, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 50.0)]); + // Contours that meet along a seam that bends within the band, one drawn each way along it. + let bent = polygon_path(&[ + &[(0.0, 0.0), (50.0, 0.0), (70.0, 47.5), (50.0, 100.0), (0.0, 100.0)], + &[(50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0), (70.0, 47.5)], + ]); + let cells = Region::cells(&bent, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + // Contours side by side, meeting along a vertical edge. + let side = polygon_path(&[&[(0.0, 0.0), (50.0, 0.0), (50.0, 100.0), (0.0, 100.0)], &[(50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0)]]); + let cells = Region::cells(&side, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + // The same, with an extra anchor on one side of the shared edge. + let split = polygon_path(&[ + &[(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (50.0, 100.0), (0.0, 100.0)], + &[(50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0)], + ]); + let cells = Region::cells(&split, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + // A slanted shared edge with an extra anchor on one side. + let slanted = polygon_path(&[ + &[(0.0, 0.0), (40.0, 0.0), (50.0, 50.0), (60.0, 100.0), (0.0, 100.0)], + &[(40.0, 0.0), (100.0, 0.0), (100.0, 100.0), (60.0, 100.0)], + ]); + let cells = Region::cells(&slanted, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + // Slanted edges of two contours 1e-7 pt apart are not one edge: the line stops at each. + let near = polygon_path(&[ + &[(0.0, 0.0), (40.0, 0.0), (60.0, 100.0), (0.0, 100.0)], + &[(40.0 + 1e-7, 0.0), (100.0, 0.0), (100.0, 100.0), (60.0 + 1e-7, 100.0)], + ]); + let cells = Region::cells(&near, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(0.0, 48.0), (52.0 + 1e-7, 100.0)]); + // The same 1e-6 pt apart, 1e8 pt from the origin. + let (x, y) = (1e8, 1e8); + let far = polygon_path(&[ + &[(x, y), (x + 40.0, y), (x + 60.0, y + 100.0), (x, y + 100.0)], + &[(x + 40.0 + 1e-6, y), (x + 100.0, y), (x + 100.0, y + 100.0), (x + 60.0 + 1e-6, y + 100.0)], + ]); + let cells = Region::cells(&far, &LayoutOptions::default(), &[]); + let spans = cells[0].spans(y + 40.0, y + 60.0); + assert!(spans.len() == 2 && spans[0].1 < x + 48.001 && spans[1].0 > x + 51.999, "{spans:?}"); + // A real gap of 0.01 pt between contours far from the origin stays a gap. + let x = 1e8; + let apart = polygon_path(&[ + &[(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 100.0), (x, 100.0)], + &[(x + 50.01, 0.0), (x + 100.0, 0.0), (x + 100.0, 100.0), (x + 50.01, 100.0)], + ]); + let cells = Region::cells(&apart, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(x, x + 50.0), (x + 50.01, x + 100.0)]); +} + +/// A frame of 200 thin slanted strips, whose edges in the band overlap in x far more than +/// [`MAX_EDGE_PAIRS`] times, holds no line in the band (no strip holds a vertical line across it). +#[cfg(test)] +#[test] +fn many_overlapping_edges_in_a_band_hold_no_line_outside_the_frame() { + let strips: Vec> = + (0..200).map(|i| f64::from(i) * 2.0).map(|x| vec![(x, 0.0), (x + 1.0, 0.0), (x + 501.0, 100.0), (x + 500.0, 100.0)]).collect(); + let strips: Vec<&[(f64, f64)]> = strips.iter().map(Vec::as_slice).collect(); + let cells = Region::cells(&polygon_path(&strips), &LayoutOptions::default(), &[]); + assert!(cells[0].spans(40.0, 60.0).is_empty()); + assert!(!cells[0].spans(50.0, 50.0).is_empty()); +} + +/// A frame shorter than the band's margin at the frame's top and bottom holds no line and does +/// not panic; a rectangle keeps its inset spans, top and bottom. +#[cfg(test)] +#[test] +fn flat_frames_and_rectangles_keep_their_spans() { + for height in [0.0, 1e-5] { + let flat = polygon_path(&[&[(0.0, 0.0), (10.0, 0.0), (0.0, height)], &[(20.0, 0.0), (30.0, 0.0), (20.0, height)]]); + let cells = Region::cells(&flat, &LayoutOptions::default(), &[]); + assert!(!cells[0].rect); + assert!(cells[0].spans(-5.0, 5.0).iter().all(|&(a, b)| b - a < 1e-3)); + } + let square = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let cells = Region::cells(&square, &LayoutOptions { inset: 10.0, ..LayoutOptions::default() }, &[]); + assert!(cells[0].rect); + assert_eq!((cells[0].top(), cells[0].bottom()), (10.0, 90.0)); + assert_spans(&cells[0].spans(40.0, 60.0), &[(10.0, 90.0)]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..c1a266247b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1249,6 +1249,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). + In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. From ef331e8fad866879b5bdc4d537295d20107e5efc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:51:19 -0800 Subject: [PATCH 2/4] Type: Invert Wrap keeps a line where two parts of the shape bridge a gap together With Invert Wrap and an offset, each piece of a line's band kept only the x that one part of the shape held within the offset of every height of the piece. Where the offsets of two parts met across a gap (one above and one below), the heights between them held neither part, and the line lost that x although the grown shape covered the whole band. The band is now also cut at every height an offset above or below an anchor of the shape (when there are at most 64 of them), and the horizontal edges at the ends of a piece's window count, so the window of the piece between the meeting offsets reaches both parts. --- crates/text/src/layout/region.rs | 58 +++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index f0bdc315c3..6f80ae9ac5 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -256,6 +256,25 @@ fn band_cover(polys: &[Vec], top: f64, bottom: f64, admit: bool) -> Vec<( union_intervals(iv) } +/// [`band_cover`] with `admit`, plus the x of every horizontal edge that lies along the top or +/// bottom of the band (`top < bottom`), counted by parity as in [`band_boundaries`]: the x at which +/// the shape or its outline meets the closed band. +fn band_cover_closed(polys: &[Vec], top: f64, bottom: f64) -> Vec<(f64, f64)> { + let mut iv = band_cover(polys, top, bottom, true); + for y in [top, bottom] { + let mut xs: Vec = polys + .iter() + .flat_map(|poly| poly.iter().zip(poly.iter().cycle().skip(1))) + .filter(|(a, b)| a.y == y && b.y == y) + .flat_map(|(a, b)| [a.x, b.x]) + .collect(); + xs.sort_by(f64::total_cmp); + iv.extend(xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1]))); + } + iv.retain(|e| e.1 > e.0); + union_intervals(iv) +} + /// Every x at which an edge of `polys` bounds the shape within the band `top..bottom` /// (`top <= bottom`), sorted and disjoint, leaving out edges that only touch the top or bottom of /// the band, stretches of edges that an even number of edges cover, and vertical edges. The flag is @@ -341,11 +360,30 @@ impl Region { // offset of every height from `y1` less the offset to `y0` plus the offset, so an x // the shape holds at any of them is. Text within the offset of such an x, for every // piece, stays inside the grown shape; finer pieces give it more of that room. + // + // The band is also cut at every height an offset above or below an anchor of the + // shape (when there are at most [`INVERT_WRAP_PIECES`] of them). Within a piece + // between such cuts, no anchor enters or leaves the offset window of any of its + // heights, so where two parts of the shape bridge a gap together (one within the + // offset above and one below), the heights from `y1` less the offset to `y0` plus + // the offset reach from one to the other, and the horizontal edges they end at + // count. let pieces = if *off > 0.0 { ((bottom - top) * 4.0 / off).ceil().clamp(1.0, INVERT_WRAP_PIECES) } else { 1.0 }; - for k in 0..pieces as usize { - let at = |k: usize| top + (bottom - top) * k as f64 / pieces; - let (a, b) = (at(k) + off, at(k + 1) - off); - let held = if a <= b { band_intervals(polys, a, b) } else { band_cover(polys, b, a, true) }; + let mut cuts: Vec = (0..=pieces as usize).map(|k| top + (bottom - top) * k as f64 / pieces).collect(); + if *off > 0.0 { + let mut anchors: Vec = + polys.iter().flatten().flat_map(|p| [p.y - off, p.y + off]).filter(|&y| y > top && y < bottom).collect(); + anchors.sort_by(f64::total_cmp); + anchors.dedup(); + if anchors.len() <= INVERT_WRAP_PIECES as usize { + cuts.extend(anchors); + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + } + for w in cuts.windows(2) { + let (a, b) = (w[0] + off, w[1] - off); + let held = if a <= b { band_intervals(polys, a, b) } else { band_cover_closed(polys, b, a) }; iv = intersect_intervals(&iv, &grow(held)); if iv.is_empty() { break; @@ -478,6 +516,18 @@ fn a_line_stays_clear_of_a_wrap_object_that_reaches_into_its_band() { let path = PathData::from_bezpath(&polygon_path(&[upper, lower])); let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0), &[(40.0, 45.0)]); + // An Invert Wrap shape of two parts whose offsets meet across the gap between them (45.25 + 5 + // reaches past 55.125 - 5) keeps the whole line. + let upper: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 0.0), (100.0, 45.25), (0.0, 45.25)]; + let lower: &[(f64, f64)] = &[(0.0, 55.125), (100.0, 55.125), (100.0, 100.0), (0.0, 100.0)]; + let path = PathData::from_bezpath(&polygon_path(&[upper, lower])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; + assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0), &[(0.0, 100.0)]); + // A gap the offsets do not bridge (45 + 5 stops short of 55.125 - 5) holds no line. + let upper: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 0.0), (100.0, 45.0), (0.0, 45.0)]; + let path = PathData::from_bezpath(&polygon_path(&[upper, lower])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; + assert!(Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0).is_empty()); } /// A hole that starts and ends inside a line's band splits the line, and a band below the hole, or From 6664a9bdb1902797451d9319b607fdb0ae465719 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:55:08 -0800 Subject: [PATCH 3/4] Type: a hairline gap between vertical edges of a frame still splits the line Crossings of the middle of a line's band that lay within a few units of rounding of each other were joined as a seam, so two contours of an area type frame 1e-7 pt apart at x = 1e8 held one line across the gap between them. A vertical edge crosses the middle at its own x with no rounding, so a gap at one is no longer joined. --- crates/text/src/layout/region.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 6f80ae9ac5..1ed38e03f1 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -226,10 +226,21 @@ fn band_intervals(polys: &[Vec], top: f64, bottom: f64) -> Vec<(f64, f64) let (top, bottom) = (top.min(bottom), top.max(bottom)); // Contours that meet along an edge cross the middle of the band at the same x, up to a few // units of rounding: the intervals on either side of it join (an edge that bounds the - // shape there is still subtracted below). - let mut mid = poly_intervals(polys, (top + bottom) * 0.5); + // shape there is still subtracted below). A vertical edge crosses the middle at its own x with + // no rounding, so a gap at one is never taken for a seam. + let y = (top + bottom) * 0.5; + let mut upright: Vec = polys + .iter() + .flat_map(|poly| poly.iter().zip(poly.iter().cycle().skip(1))) + .filter(|(a, b)| a.x == b.x && (a.y <= y) != (b.y <= y)) + .map(|(a, _)| a.x) + .collect(); + upright.sort_by(f64::total_cmp); + let upright = |x: f64| upright.binary_search_by(|v| v.total_cmp(&x)).is_ok(); + let mut mid = poly_intervals(polys, y); mid.dedup_by(|next, prev| { - let seam = next.0 - prev.1 <= 8.0 * f64::EPSILON * prev.1.abs().max(next.0.abs()).max(1.0); + let gap = next.0 - prev.1; + let seam = gap <= 8.0 * f64::EPSILON * prev.1.abs().max(next.0.abs()).max(1.0) && (gap <= 0.0 || !(upright(prev.1) || upright(next.0))); if seam { prev.1 = prev.1.max(next.1); } @@ -609,6 +620,13 @@ fn lines_cross_an_edge_two_contours_share() { ]); let cells = Region::cells(&apart, &LayoutOptions::default(), &[]); assert_spans(&cells[0].spans(40.0, 60.0), &[(x, x + 50.0), (x + 50.01, x + 100.0)]); + // So does a gap of 1e-7 pt between vertical edges there, a few units of rounding wide. + let apart = polygon_path(&[ + &[(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 100.0), (x, 100.0)], + &[(x + 50.0 + 1e-7, 0.0), (x + 100.0, 0.0), (x + 100.0, 100.0), (x + 50.0 + 1e-7, 100.0)], + ]); + let cells = Region::cells(&apart, &LayoutOptions::default(), &[]); + assert_spans(&cells[0].spans(40.0, 60.0), &[(x, x + 50.0), (x + 50.0 + 1e-7, x + 100.0)]); } /// A frame of 200 thin slanted strips, whose edges in the band overlap in x far more than From a55116c144bb6e4439ae58af55b03cf600b11ac5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:16:46 -0800 Subject: [PATCH 4/4] Type: Invert Wrap with an offset applies to a band of no height A band whose top and bottom are equal lost its only piece when the repeated cut was removed, so the line kept the frame's whole width. Anchor cuts now apply only to a band with height. --- crates/text/src/layout/region.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 3700541433..d7293c1109 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -446,7 +446,9 @@ impl Region { // count. let pieces = if *off > 0.0 { ((bottom - top) * 4.0 / off).ceil().clamp(1.0, INVERT_WRAP_PIECES) } else { 1.0 }; let mut cuts: Vec = (0..=pieces as usize).map(|k| top + (bottom - top) * k as f64 / pieces).collect(); - if *off > 0.0 { + // A band of no height stays one piece: no anchor lies strictly inside it, and + // removing the repeated cut would leave no piece. + if *off > 0.0 && bottom > top { let mut anchors: Vec = polys.iter().flatten().flat_map(|p| [p.y - off, p.y + off]).filter(|&y| y > top && y < bottom).collect(); anchors.sort_by(f64::total_cmp); @@ -615,6 +617,11 @@ fn a_line_stays_clear_of_a_wrap_object_that_reaches_into_its_band() { let path = PathData::from_bezpath(&closed(&[upper, lower])); let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; assert!(Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(40.0, 60.0).is_empty()); + // A band of no height keeps Invert Wrap and its offset. + let strip: &[(f64, f64)] = &[(20.0, 0.0), (40.0, 0.0), (40.0, 100.0), (20.0, 100.0)]; + let path = PathData::from_bezpath(&closed(&[strip])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 5.0, invert: true } }; + assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(50.0, 50.0), &[(15.0, 45.0)]); } /// A hole that starts and ends inside a line's band splits the line, and a band below the hole, or