diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 81cdad56a..2942fb962 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -6159,21 +6159,72 @@ fn vertical_justify( shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, shift[k])) }) && clear(shift) }; + // The room in 0..=`top` at which a moved line's band (from its top down to its baseline, or + // to its descent where `inside`), widened by the outline's inset, reaches one of the + // outline's corner heights: between two of these each line's fit changes only along the + // outline's straight edges. Empty when there are more than `MAX_BAND_BREAKS`. + const MAX_BAND_BREAKS: usize = 256; + let band_breaks = |o: &FrameOutline, top: f64| -> Vec { + let corners = o.corner_heights(); + let d = o.inset(); + // Each row's shift is linear in the room, apart from Justify's turn where the space + // between paragraphs reaches the Paragraph Spacing Limit. + let mut knots = vec![0.0, top]; + let turn = gaps as f64 * para_limit; + if f.opts.vertical_justification == VerticalJustification::Justify && turn > 0.0 && turn < top { + knots.insert(1, turn); + } + let at: Vec> = knots.iter().map(|&c| shifts(c)).collect(); + let mut out = Vec::new(); + for (&i, &k) in idx.iter().zip(&rows) { + let l = &ft.lines[i]; + let desc = if inside { l.descent.max(0.0) } else { 0.0 }; + for &y in &corners { + for v in [y + d - (l.baseline - l.ascent), y - d - (l.baseline + desc)] { + for (c, s) in knots.windows(2).zip(at.windows(2)) { + let (s0, s1) = (s[0][k], s[1][k]); + if s1 > s0 && v > s0 && v < s1 { + out.push(c[0] + (v - s0) * (c[1] - c[0]) / (s1 - s0)); + } + } + } + if out.len() > MAX_BAND_BREAKS { + return Vec::new(); + } + } + } + out + }; // In a rectangular frame fit decreases monotonically as the shift grows. A shaped // frame can narrow and widen again, so scan from the largest shift before refining the - // first fitting interval. This also bounds the room used for baseline-grid quantization - // and preserves clearance from fixed and anchor-following wraps. + // first fitting interval. The scan takes even steps, the band breaks and a room on each + // side of every band break, so a band that fits over less room than one step is found + // too. This also bounds the room used for baseline-grid quantization and preserves + // clearance from fixed and anchor-following wraps. let most_room = |top: f64| -> f64 { if fits(&shifts(top)) { return top; } - let (mut lo, mut hi) = if f.outline.is_some() { + let (mut lo, mut hi) = if let Some(o) = &f.outline { let steps = top.ceil().clamp(64.0, 512.0) as usize; + // Each try is marked when it is a band break; only the gaps next to a break get a + // try in their middle. + let mut marked: Vec<(f64, bool)> = (0..steps).map(|j| (top * j as f64 / steps as f64, false)).collect(); + marked.extend(band_breaks(o, top).into_iter().map(|c| (c, true))); + marked.push((top, false)); + marked.sort_by(|a, b| a.0.total_cmp(&b.0)); + marked.dedup_by(|a, b| { + let same = (a.0 - b.0).abs() < 1e-9; + b.1 |= same && a.1; + same + }); + let mut tries: Vec = marked.windows(2).filter(|w| w[0].1 || w[1].1).map(|w| (w[0].0 + w[1].0) / 2.0).collect(); + tries.extend(marked.iter().map(|m| m.0)); + tries.sort_by(f64::total_cmp); let mut found = (0.0, top); - for j in 1..=steps { - let candidate = top * (steps - j) as f64 / steps as f64; - if fits(&shifts(candidate)) { - found = (candidate, top * (steps - j + 1) as f64 / steps as f64); + for w in tries.windows(2).rev() { + if fits(&shifts(w[0])) { + found = (w[0], w[1]); break; } } diff --git a/crates/compose/src/outline.rs b/crates/compose/src/outline.rs index dd1f59c0e..dc7d97ac3 100644 --- a/crates/compose/src/outline.rs +++ b/crates/compose/src/outline.rs @@ -71,6 +71,20 @@ impl FrameOutline { FrameOutline { edges: self.edges.iter().map(|&(a, b)| (xf * a, xf * b)).collect(), inset: self.inset } } + /// The distance kept between text and the outline. + pub fn inset(&self) -> f64 { + self.inset + } + + /// The heights of the outline's corners, sorted and without repeats: the inside intervals of a + /// scanline change linearly between two of them. + pub fn corner_heights(&self) -> Vec { + let mut ys: Vec = self.edges.iter().map(|(a, _)| a.y).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + ys + } + /// The parts of `[x0, x1]` inside the outline over the whole band `[y0, y1]`, at least the /// inset away from it: the inside intervals at every height of the band are intersected, and /// the inset is kept by testing the heights within the inset above and below the band, each diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a0eac4061..e28b1265a 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3206,6 +3206,39 @@ fn vertical_justification_lets_earlier_descenders_cross_a_shaped_frame() { } } +/// In a tall shaped frame, Bottom finds a band that the line fits in over less room than one step +/// of an even scan: in a frame that is half width from 40 pt down, apart from a full-width band +/// only 0.25 pt taller than the line at 1,800 pt, the line moves into that band. +#[test] +fn vertical_justification_finds_a_narrow_band_in_a_tall_shaped_frame() { + use designcraft_doc::VerticalJustification::{self, Bottom, Top}; + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let compose_with = |pts: &[(f64, f64)], mode: VerticalJustification| -> Vec { + let (mut d, sid, fid) = doc_with("Typography", Rect::new(0.0, 0.0, 300.0, 2000.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + if !pts.is_empty() { + it.path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); + } + let o = &mut it.text_frame_mut().unwrap().options; + o.vertical_justification = mode; + o.inset = [0.0; 4]; + assert_eq!(frame_specs(&d, sid)[0].outline.is_some(), !pts.is_empty()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "{mode:?}"); + all_lines(&cs).into_iter().cloned().collect() + }; + let ascent = compose_with(&[], Top)[0].ascent; + assert!(ascent > 1.0, "ascent {ascent}"); + let (y0, y1) = (1800.0, 1800.0 + ascent + 0.25); + let pts = + [(0.0, 0.0), (300.0, 0.0), (300.0, 40.0), (150.0, 40.0), (150.0, y0), (300.0, y0), (300.0, y1), (150.0, y1), (150.0, 2000.0), (0.0, 2000.0)]; + let lines = compose_with(&pts, Bottom); + assert_eq!(lines.len(), 1, "{lines:?}"); + let l = &lines[0]; + assert!((l.x1 - 300.0).abs() < 1e-6, "slot {l:?}"); + assert!(l.baseline - l.ascent >= y0 - 1e-6 && l.baseline <= y1 + 1e-6, "baseline at {}, band {y0}..{y1}", l.baseline); +} + /// In a shaped frame with a text wrap under the text, Center, Bottom and Justify keep the lines /// inside the outline and out of the wrap together: in a triangle that widens downward, Bottom and /// Justify put the last baseline on the wrap's top and Center moves half as far; in one that