diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index b8bad9a246..e80d890cef 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -386,7 +386,7 @@ fn slant_boundaries(slants: &mut [Slant]) -> (Vec<(f64, f64)>, bool, Vec) } /// The most pieces a line's band is cut into to keep it inside an Invert Wrap shape grown by its -/// offset. +/// offset, when [`grown_band`] gives up, or out of a wrap shape shrunk 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 @@ -582,6 +582,297 @@ fn eroded_cover(polys: &[Vec], top: f64, bottom: f64, d: f64) -> Vec<(f64 union_intervals(out) } +/// The most steps [`grown_band`] takes for one band and one wrap shape: edges, pairs of edges and +/// pairs of grown pieces looked at, and pieces looked at for each x it tests. +const MAX_INVERT_WRAP_WORK: usize = 1 << 20; + +#[cfg(test)] +thread_local! { + /// How many steps [`grown_band`] took on this thread. + static GROWN_BAND_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `n` to `work`; false when that passes [`MAX_INVERT_WRAP_WORK`]. +fn spend(work: &mut usize, n: usize) -> bool { + *work = work.saturating_add(n); + #[cfg(test)] + GROWN_BAND_WORK.set(GROWN_BAND_WORK.get().saturating_add(n)); + *work <= MAX_INVERT_WRAP_WORK +} + +/// The x of the edge `p`–`q` (not horizontal) at `y`: the x of its end at that end's height. +fn edge_x(p: Point, q: Point, y: f64) -> f64 { + if y == p.y { + p.x + } else if y == q.y { + q.x + } else { + line_x(p, q, y) + } +} + +/// A trapezoid with a horizontal top and bottom: its top and bottom heights, then the x of its left +/// edge at each of them, then the x of its right edge at each of them. +type Trapezoid = (f64, f64, f64, f64, f64, f64); + +/// The shape `polys` (even-odd) between the heights `y0..y1` (`y0 < y1`) as trapezoids with +/// horizontal tops and bottoms, cut at each anchor's height and wherever two edges cross, whose union +/// (each trapezoid with its outline) is the shape with its outline there. Edges that lie along one +/// another give trapezoids that touch, and contours that cancel each other give none. `None` when +/// [`MAX_INVERT_WRAP_WORK`] runs out. +fn trapezoids(polys: &[Vec], y0: f64, y1: f64, work: &mut usize) -> Option> { + let mut edges: Vec<(Point, Point)> = polys + .iter() + .flat_map(|p| (0..p.len()).map(move |i| (p[i], p[(i + 1) % p.len()]))) + .filter(|(a, b)| a.y != b.y && a.y.max(b.y) > y0 && a.y.min(b.y) < y1) + .map(|(a, b)| if a.y < b.y { (a, b) } else { (b, a) }) + .collect(); + if !spend(work, edges.len()) { + return None; + } + edges.sort_by(|u, v| u.0.y.total_cmp(&v.0.y)); + let mut ys: Vec = edges.iter().flat_map(|(p, q)| [p.y, q.y]).filter(|&y| y > y0 && y < y1).chain([y0, y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut out = Vec::new(); + let (mut next, mut active) = (0, Vec::new()); + for w in ys.windows(2) { + let (a, b) = (w[0], w[1]); + // The edges that span the slab `a..b`: every anchor's height is a cut, so an edge that + // starts at or above `a` and ends below it reaches `b`. + while let Some(&e) = edges.get(next).filter(|e| e.0.y <= a) { + active.push(e); + next += 1; + } + active.retain(|e: &(Point, Point)| e.1.y >= b); + if !spend(work, active.len()) { + return None; + } + // The heights within the slab at which two edges cross: their order left to right differs + // at its top and bottom. + let mut cuts = vec![a, b]; + let ends: Vec<(f64, f64)> = active.iter().map(|&(p, q)| (edge_x(p, q, a), edge_x(p, q, b))).collect(); + let mut order: Vec = (0..ends.len()).collect(); + order.sort_by(|&i, &j| ends[i].0.total_cmp(&ends[j].0).then(ends[i].1.total_cmp(&ends[j].1))); + if order.windows(2).any(|w| ends[w[0]].1 > ends[w[1]].1) { + if !spend(work, ends.len() * ends.len()) { + return None; + } + for (i, &(sa, sb)) in ends.iter().enumerate() { + for &(ta, tb) in &ends[i + 1..] { + let (da, db) = (sa - ta, sb - tb); + if da * db < 0.0 { + let y = a + (b - a) * (da / (da - db)); + if y > a && y < b { + cuts.push(y); + } + } + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + for c in cuts.windows(2) { + let (c, d) = (c[0], c[1]); + // No two edges cross within `c..d`, so their order at its middle holds throughout, and + // the shape (even-odd) lies between the first and second, the third and fourth, and so on. + let mid = (c + d) * 0.5; + let mut xs: Vec<(f64, f64, f64)> = active.iter().map(|&(p, q)| (line_x(p, q, mid), edge_x(p, q, c), edge_x(p, q, d))).collect(); + if !spend(work, xs.len()) { + return None; + } + xs.sort_by(|u, v| u.0.total_cmp(&v.0)); + for pair in xs.as_chunks::<2>().0 { + let ((_, l0, l1), (_, r0, r1)) = (pair[0], pair[1]); + if r0 > l0 || r1 > l1 { + out.push((c, d, l0, l1, r0, r1)); + } + } + } + } + Some(out) +} + +/// A convex polygon as its lower and upper outlines (the least and the greatest y at each x), each +/// left to right from its least x to its greatest, with its x range and y range. +struct Convex { + lower: Vec, + upper: Vec, + xs: (f64, f64), + ys: (f64, f64), +} + +impl Convex { + /// The convex hull of `pts`. + fn hull(mut pts: Vec) -> Convex { + let cross = |o: Point, a: Point, b: Point| (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + let chain = |pts: &[Point], turns: fn(f64) -> bool| { + let mut out: Vec = Vec::with_capacity(pts.len()); + for &p in pts { + while let [.., o, a] = out[..] { + if turns(cross(o, a, p)) { + break; + } + out.pop(); + } + out.push(p); + } + // The points at the greatest x after the first are on the other outline. + while let [.., a, b] = out[..] { + if a.x != b.x { + break; + } + out.pop(); + } + out + }; + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(p.y.total_cmp(&q.y))); + let lower = chain(&pts, |c| c > 0.0); + pts.sort_by(|p, q| p.x.total_cmp(&q.x).then(q.y.total_cmp(&p.y))); + let upper = chain(&pts, |c| c < 0.0); + let xs = (lower.first().map_or(f64::NAN, |p| p.x), lower.last().map_or(f64::NAN, |p| p.x)); + let ys = (lower.iter().fold(f64::INFINITY, |m, p| m.min(p.y)), upper.iter().fold(f64::NEG_INFINITY, |m, p| m.max(p.y))); + Convex { lower, upper, xs, ys } + } + + /// The y range of the polygon at `x`, within its x range. + fn at(&self, x: f64) -> (f64, f64) { + (chain_y(&self.lower, x), chain_y(&self.upper, x)) + } +} + +/// The y of the outline `chain` (left to right, no two points at one x) at `x`, within its x range. +fn chain_y(chain: &[Point], x: f64) -> f64 { + let i = chain.partition_point(|p| p.x < x); + match (i.checked_sub(1).and_then(|h| chain.get(h)), chain.get(i)) { + (Some(p), Some(q)) => p.y + (x - p.x) / (q.x - p.x) * (q.y - p.y), + (None, Some(q)) => q.y, + (Some(p), None) => p.y, + (None, None) => f64::NAN, + } +} + +/// The x at which some segment of `a` and some segment of `b` (outlines left to right) are at the +/// same y, strictly between the ends of the x range they share. +fn chain_crossings(a: &[Point], b: &[Point], out: &mut Vec, work: &mut usize) -> bool { + for s in a.windows(2) { + let first = b.partition_point(|q| q.x <= s[0].x).saturating_sub(1); + for t in b[first..].windows(2).take_while(|t| t[0].x < s[1].x) { + if !spend(work, 1) { + return false; + } + let (x0, x1) = (s[0].x.max(t[0].x), s[1].x.min(t[1].x)); + if x1 <= x0 { + continue; + } + let (d0, d1) = (chain_y(s, x0) - chain_y(t, x0), chain_y(s, x1) - chain_y(t, x1)); + if d0 * d1 < 0.0 { + out.push(x0 + (x1 - x0) * (d0 / (d0 - d1))); + } + } + } + true +} + +/// The x within `lo..hi` at which the shape `polys` (even-odd) grown by `off` (> 0) across and +/// along holds every height of the band `top..bottom` (`top <= bottom`): the x whose vertical line +/// across the band has each of its points within `off`, across and along, of the shape or its +/// outline. The shape from `off` above the band to `off` below it, and on to the nearest anchors +/// past those heights, is cut into [`trapezoids`]; each +/// grown by `off` is a convex polygon, and the line at an x lies in their union when the y ranges +/// they give at that x join up across the band. Those ranges change only at the polygons' corners +/// and where their outlines cross each other or the band's top or bottom, so the line is tested +/// once between each two of those x. `None` when that takes more than [`MAX_INVERT_WRAP_WORK`] +/// steps. +fn grown_band(polys: &[Vec], off: f64, top: f64, bottom: f64, (lo, hi): (f64, f64)) -> Option> { + let mut work = 0; + // The trapezoids reach on to the nearest anchor past `off` above and below the band, so that + // the shape's outline exactly `off` from the band counts. + let (y0, y1) = (top - off, bottom + off); + let ys = || polys.iter().flatten().map(|p| p.y); + let below = ys().filter(|&y| y < y0).fold(f64::NEG_INFINITY, f64::max); + let above = ys().filter(|&y| y > y1).fold(f64::INFINITY, f64::min); + let traps = trapezoids(polys, if below.is_finite() { below } else { y0 }, if above.is_finite() { above } else { y1 }, &mut work)?; + let mut grown: Vec = traps + .into_iter() + .filter(|&(_, _, l0, l1, r0, r1)| l0.min(l1) - off < hi && r0.max(r1) + off > lo) + .map(|(y0, y1, l0, l1, r0, r1)| { + let corners = [(l0, y0), (r0, y0), (l1, y1), (r1, y1)]; + let pts = corners + .into_iter() + .flat_map(|(x, y)| [(-off, -off), (off, -off), (-off, off), (off, off)].map(|(dx, dy)| Point::new(x + dx, y + dy))); + Convex::hull(pts.collect()) + }) + .collect(); + grown.sort_by(|p, q| p.xs.0.total_cmp(&q.xs.0)); + // The x at which the line's place among the polygons can change. + let mut xs = vec![lo, hi]; + for (j, p) in grown.iter().enumerate() { + xs.extend(p.lower.iter().chain(&p.upper).map(|v| v.x)); + for chain in [&p.lower, &p.upper] { + for s in chain.windows(2) { + for y in [top, bottom] { + let (d0, d1) = (s[0].y - y, s[1].y - y); + if d0 * d1 < 0.0 { + xs.push(s[0].x + (s[1].x - s[0].x) * (d0 / (d0 - d1))); + } + } + } + } + let ((_, x1), (y0, y1)) = (p.xs, p.ys); + for q in grown[j + 1..].iter().take_while(|q| q.xs.0 < x1) { + if !spend(&mut work, 1) { + return None; + } + let (v0, v1) = q.ys; + if v0 <= y1 + && y0 <= v1 + && !(chain_crossings(&p.upper, &q.lower, &mut xs, &mut work) && chain_crossings(&q.upper, &p.lower, &mut xs, &mut work)) + { + return None; + } + } + } + xs.retain(|&x| x >= lo && x <= hi); + xs.sort_by(f64::total_cmp); + xs.dedup(); + // Test the line between each two of those x, walking left to right with the polygons whose x + // range holds it. + let mut out: Vec<(f64, f64)> = Vec::new(); + let (mut next, mut active, mut ranges): (usize, Vec<&Convex>, Vec<(f64, f64)>) = (0, Vec::new(), Vec::new()); + for w in xs.windows(2) { + let x = (w[0] + w[1]) * 0.5; + while let Some(p) = grown.get(next).filter(|p| p.xs.0 <= x) { + active.push(p); + next += 1; + } + active.retain(|p| p.xs.1 >= x); + if !spend(&mut work, active.len()) { + return None; + } + ranges.clear(); + ranges.extend(active.iter().map(|p| p.at(x))); + ranges.sort_by(|u, v| u.0.total_cmp(&v.0)); + // The line is held from the band's top to `reach`, up to the first range that starts past + // it; a band of no height is held when a range reaches it. + let (mut reach, mut met) = (top, false); + for &(a, b) in &ranges { + if a > reach { + break; + } + reach = reach.max(b); + met |= b >= top; + } + if met && reach >= bottom { + match out.last_mut() { + Some(l) if l.1 >= w[0] => l.1 = w[1], + _ => out.push((w[0], w[1])), + } + } + } + Some(out) +} + 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. @@ -654,10 +945,20 @@ impl Region { } else { subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off))) } + } else if let Some(held) = (*invert && *off > 0.0) + .then(|| iv.first().zip(iv.last()).and_then(|(f, l)| grown_band(polys, *off, top, bottom, (f.0 - inset, l.1 + inset)))) + .flatten() + { + // The x at which the shape grown by the offset holds the whole band. + intersect_intervals(&iv, &shrink(held)) } else 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 + // With no offset, or when measuring the grown shape exactly takes more than + // [`MAX_INVERT_WRAP_WORK`] steps, the band is cut into pieces, which can leave a line + // shorter than the grown shape allows, never longer. + // + // 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 @@ -1255,8 +1556,8 @@ fn intersecting_intervals_looks_at_each_interval_once() { } /// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside -/// each tooth, left to right, through every piece of its band, and intersecting the spans with each -/// piece looks at each span once. +/// each tooth, left to right, and measuring the grown comb takes a few dozen steps for each tooth, +/// not one for each pair of teeth. #[cfg(test)] #[test] fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { @@ -1268,11 +1569,92 @@ fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } }; let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); INTERSECT_PAIRS.set(0); + GROWN_BAND_WORK.set(0); let got = cells[0].spans(40.0, 60.0); let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect(); assert_spans(&got, &want); - // At most 64 pieces, each looking at no more than the spans so far and the tooth spans. - assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get()); + // The line's one span is intersected once with the 500 spans the grown comb holds. + assert!(INTERSECT_PAIRS.get() <= 1 + 500, "{} pairs", INTERSECT_PAIRS.get()); + assert!(GROWN_BAND_WORK.get() <= 40 * 500, "{} steps", GROWN_BAND_WORK.get()); +} + +/// The spans of a 120 pt wide, 100 pt tall frame over the band `top..bottom` with one Invert Wrap +/// object through `contour` and `offset`. +#[cfg(test)] +fn invert_wrap_spans(contour: &[(f64, f64)], offset: f64, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let frame = Rect::new(0.0, 0.0, 120.0, 100.0).to_path(0.1); + let path = PathData::from_bezpath(&closed(&[contour])); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset, invert: true } }; + Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom) +} + +/// The 100 pt square whose right edge steps in to x = 80 from `y0` to `y1`, with 79 more anchors +/// along its left edge, which leave its shape as it is. +#[cfg(test)] +fn notched_square(y0: f64, y1: f64) -> Vec<(f64, f64)> { + let mut contour = vec![(0.0, 0.0), (100.0, 0.0), (100.0, y0), (80.0, y0), (80.0, y1), (100.0, y1), (100.0, 100.0), (0.0, 100.0)]; + contour.extend((1..80).rev().map(|i| (0.0, 1.25 * f64::from(i)))); + contour +} + +/// With Invert Wrap and an offset, a line beside a notch the offset fills reaches as far as the +/// grown shape does at every height of its band, however tall the band and however many anchors +/// the shape has: a notch 0.5625 pt tall with an offset of 1, and one 0.4 pt tall with an offset +/// of 0.25 over a band of 100 pt. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_notch_reach_the_grown_shape() { + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 0.0, 100.0), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.6, 51.0), 0.25, 0.0, 100.0), &[(0.0, 100.25)]); + // A band of no height in the notch, and one in a notch the offset does not fill. + assert_spans(&invert_wrap_spans(¬ched_square(50.5, 51.0625), 1.0, 50.7, 50.7), &[(0.0, 101.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 0.0, 100.0), &[(0.0, 81.0)]); + assert_spans(&invert_wrap_spans(¬ched_square(50.0, 53.0), 1.0, 51.5, 51.5), &[(0.0, 81.0)]); +} + +/// With Invert Wrap and an offset, a line beside a notch with sloped sides reaches as far as the +/// grown shape does at the notch's tip: 1 pt above and below the tip, each side is 8 pt from it. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_lets_a_line_beside_a_sloped_notch_reach_the_grown_shape() { + let notch = [(0.0, 0.0), (100.0, 0.0), (100.0, 45.15), (60.0, 50.15), (100.0, 55.15), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&invert_wrap_spans(¬ch, 1.0, 40.0, 60.0), &[(0.0, 69.0)]); + assert_spans(&invert_wrap_spans(¬ch, 5.0, 40.0, 60.0), &[(0.0, 105.0)]); +} + +/// With Invert Wrap and an offset, a band of no height exactly the offset past a wrap shape's +/// bottom edge or its tip still holds a line as wide as the grown edge or tip. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_holds_a_band_exactly_the_offset_past_the_shape() { + let bar = [(20.0, 0.0), (60.0, 0.0), (60.0, 10.0), (20.0, 10.0)]; + assert_spans(&invert_wrap_spans(&bar, 1.0, 11.0, 11.0), &[(19.0, 61.0)]); + assert!(invert_wrap_spans(&bar, 1.0, 11.5, 11.5).is_empty()); + let tip = [(20.0, 0.0), (80.0, 0.0), (50.0, 10.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 11.0, 11.0), &[(49.0, 51.0)]); + let tip = [(20.0, 20.0), (50.0, 10.0), (80.0, 20.0)]; + assert_spans(&invert_wrap_spans(&tip, 1.0, 9.0, 9.0), &[(49.0, 51.0)]); +} + +/// A wrap shape whose edges cross one another tens of thousands of times takes no more than +/// [`MAX_INVERT_WRAP_WORK`] steps to measure exactly; it is then cut into pieces, and the line +/// still stays inside the shape grown by the offset. An offset that is not a number is 0. +#[cfg(test)] +#[test] +fn invert_wrap_with_an_offset_bounds_its_work_on_a_tangled_shape() { + let star: Vec = (0..301) + .map(|k| f64::from(k * 150) * std::f64::consts::TAU / 301.0) + .map(|a| Point::new(50.0 + 45.0 * a.cos(), 50.0 + 45.0 * a.sin())) + .collect(); + GROWN_BAND_WORK.set(0); + assert!(grown_band(std::slice::from_ref(&star), 1.0, 10.0, 90.0, (0.0, 120.0)).is_none()); + assert!(GROWN_BAND_WORK.get() <= 2 * MAX_INVERT_WRAP_WORK, "{} steps", GROWN_BAND_WORK.get()); + let contour: Vec<(f64, f64)> = star.iter().map(|p| (p.x, p.y)).collect(); + let spans = invert_wrap_spans(&contour, 1.0, 10.0, 90.0); + assert!(spans.iter().all(|&(a, b)| a >= 4.0 && b <= 96.0), "{spans:?}"); + let square = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&invert_wrap_spans(&square, f64::NAN, 45.0, 55.0), &invert_wrap_spans(&square, 0.0, 45.0, 55.0)); + assert_spans(&invert_wrap_spans(&square, 0.0, 45.0, 55.0), &[(40.0, 60.0)]); } /// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6abfa20726..111ba16bac 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1349,7 +1349,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). 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). In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`). - With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). + With Invert Wrap and a positive Offset, a line uses all the room the wrap shape grown by the Offset (across and along) holds at every height of its band: beside a notch the Offset fills, the line reaches the grown edge, whatever the band's height, the slope of the notch's sides or the number of anchors on the shape (VectorCraft's own measure). Measuring a wrap shape of many parts (a comb of 500 teeth) takes a few dozen steps per part, not one per pair of parts. The measure stops after a fixed number of steps; a shape that needs more, for example one whose edges cross one another tens of thousands of times, has its band cut into at most 64 pieces instead, which keeps the line inside the grown shape but can leave it shorter than the grown shape allows (`crates/text/src/layout/region.rs` tests). Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). 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.