diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index 9303d4c0fb..6272e54d71 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -73,6 +73,52 @@ fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect() } +/// The part of the horizontal line at `y` closer than `r` to the segment `a`–`b`: the line's +/// intersection with the segment's two end disks and the band between them, which together make +/// a convex shape, so the result is one interval. +fn near_segment(a: Point, b: Point, y: f64, r: f64) -> Option<(f64, f64)> { + if y <= a.y.min(b.y) - r || y >= a.y.max(b.y) + r { + return None; + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for p in [a, b] { + let h = r * r - (y - p.y) * (y - p.y); + if h > 0.0 { + lo = lo.min(p.x - h.sqrt()); + hi = hi.max(p.x + h.sqrt()); + } + } + let d = b - a; + let len = d.hypot(); + if len > 0.0 { + let n = kurbo::Vec2::new(-d.y, d.x) * (r / len); + let band = [a + n, b + n, b - n, a - n]; + for i in 0..4 { + let (p, q) = (band[i], band[(i + 1) % 4]); + if (p.y <= y) != (q.y <= y) { + let x = p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x); + lo = lo.min(x); + hi = hi.max(x); + } + } + } + (hi > lo).then_some((lo, hi)) +} + +/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`, less the points +/// closer than `inset` to any edge of them (holes included), measured from the edge itself. +fn inset_intervals(polys: &[Vec], y: f64, inset: f64) -> Vec<(f64, f64)> { + let iv = poly_intervals(polys, y); + if inset <= 0.0 || iv.is_empty() { + return iv; + } + if !inset.is_finite() { + return vec![]; + } + let near = polys.iter().flat_map(|p| (0..p.len()).filter_map(move |i| near_segment(p[i], p[(i + 1) % p.len()], y, inset))); + subtract_intervals(&iv, &union_intervals(near.collect())) +} + /// Union of sorted-or-not intervals. fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { v.sort_by(|a, b| a.0.total_cmp(&b.0)); @@ -168,12 +214,22 @@ impl Region { /// 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. + /// On a shaped frame the intervals keep the inset from the frame's edges, measured from the + /// edge itself. Along the line, the text also keeps the inset beside each wrap object (outside + /// it, or inside it for Invert Wrap); above and below a wrap object only its offset applies. 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) }; + let mut iv = + if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { inset_intervals(&self.polys, y, self.inset) }; 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) }; + iv = if *invert { + let w: Vec<_> = w.into_iter().map(|(a, b)| (a + self.inset, b - self.inset)).filter(|(a, b)| b > a).collect(); + intersect_intervals(&iv, &w) + } else { + let w = union_intervals(w.into_iter().map(|(a, b)| (a - self.inset, b + self.inset)).collect()); + subtract_intervals(&iv, &w) + }; } iv } @@ -186,8 +242,10 @@ impl Region { /// 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). pub(super) fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> { + // The inset from the cell's edges; `intervals` already keeps it from the frame's edges and + // beside the wrap objects. let clip = |(a, b): (f64, f64)| { - let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); + 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; @@ -238,3 +296,100 @@ 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())); } + +/// A path with one closed subpath through each list of points. +#[cfg(test)] +fn closed(subpaths: &[&[(f64, f64)]]) -> BezPath { + let mut path = BezPath::new(); + for pts in subpaths { + path.move_to(pts[0]); + for &p in &pts[1..] { + path.line_to(p); + } + path.close_path(); + } + path +} + +/// The spans of every cell of `path` over the band `top..bottom`. +#[cfg(test)] +fn cell_spans(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape], top: f64, bottom: f64) -> Vec> { + Region::cells(path, opts, wrap).iter().map(|c| c.spans(top, bottom)).collect() +} + +#[cfg(test)] +fn assert_spans(got: &[(f64, f64)], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?} != {want:?}"); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} != {want:?}"); + } +} + +/// On a triangle, the inset keeps the line 10 pt from the sloped edge, measured from the edge +/// itself (x + y <= 100 - 10√2 at the band's bottom), not 10 pt along the line. +#[cfg(test)] +#[test] +fn inset_on_a_shaped_frame_is_measured_from_its_sloped_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let inset = |inset| cell_spans(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[], 40.0, 60.0); + assert_spans(&inset(10.0)[0], &[(10.0, 40.0 - 10.0 * std::f64::consts::SQRT_2)]); + assert_spans(&inset(0.0)[0], &[(0.0, 40.0)]); +} + +/// A rectangular frame keeps its inset from each side, and its first line starts the inset below +/// its top. +#[cfg(test)] +#[test] +fn a_rectangular_frame_keeps_its_inset_from_each_side() { + let cells = Region::cells(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1), &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)]); +} + +/// The inset keeps text clear of a hole's sloped edges too. +#[cfg(test)] +#[test] +fn inset_keeps_text_clear_of_a_hole_s_sloped_edges() { + let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + let diamond = [(50.0, 30.0), (70.0, 50.0), (50.0, 70.0), (30.0, 50.0)]; + let spans = cell_spans(&closed(&[&square, &diamond]), &LayoutOptions { inset: 5.0, ..LayoutOptions::default() }, &[], 40.0, 44.0); + let clear = 5.0 * std::f64::consts::SQRT_2; + assert_spans(&spans[0], &[(5.0, 36.0 - clear), (64.0 + clear, 95.0)]); +} + +/// In a frame of columns, the inset is kept once from a column's inner edge and once from the +/// frame's sloped edge. +#[cfg(test)] +#[test] +fn inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + let spans = cell_spans(&triangle, &LayoutOptions { inset: 10.0, columns: 2, gutter: 0.0, ..LayoutOptions::default() }, &[], 10.0, 20.0); + assert_spans(&spans[0], &[(10.0, 40.0)]); + assert_spans(&spans[1], &[(60.0, 80.0 - 10.0 * std::f64::consts::SQRT_2)]); +} + +/// An inset wider than a shaped frame leaves no room for text, whatever its size. +#[cfg(test)] +#[test] +fn an_inset_wider_than_a_shaped_frame_leaves_no_room() { + let triangle = closed(&[&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)]]); + for inset in [40.0, 1e300, f64::INFINITY] { + let cells = Region::cells(&triangle, &LayoutOptions { inset, ..LayoutOptions::default() }, &[]); + assert!(cells[0].spans(40.0, 60.0).is_empty() && cells[0].span(40.0, 60.0).is_none(), "inset {inset}"); + } +} + +/// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, +/// inside it. +#[cfg(test)] +#[test] +fn text_keeps_the_inset_beside_a_wrap_object_along_the_line() { + let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let shape = PathData::from_bezpath(&Rect::new(40.0, 40.0, 60.0, 60.0).to_path(0.1)); + let opts = LayoutOptions { inset: 5.0, ..LayoutOptions::default() }; + for (invert, want) in [(false, vec![(5.0, 35.0), (65.0, 95.0)]), (true, vec![(45.0, 55.0)])] { + let wrap = vectorcraft_doc::WrapShape { path: shape.clone(), wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert } }; + assert_spans(&cell_spans(&frame, &opts, &[wrap], 45.0, 55.0)[0], &want); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..f54a8aef88 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1260,6 +1260,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. + On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (, ). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Type on a path, Shrink Text to Fit, lines aligned other than to the top, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again.