Compose: vertical justification in a shaped frame finds a band shorter than one scan step

In a shaped frame, Center, Bottom and Justify searched for the room the
text can take in at most 512 even steps, so a band that the lines fit in
over less room than one step could be missed, and the text then stayed
higher than it could sit. In a frame with more than 512 pt of room one
step is more than 1 pt.

The search now also tries the room at which a moved line's band reaches
one of the outline's corner heights, and a room on each side of each of
those. When there are more than 256 such breaks (a curved outline with
many corners), the search keeps the even steps alone.
This commit is contained in:
dotCooCoo committed 2026-11-12 18:38:14 -08:00
1 parent 437c532511
commit 7ea1dff1f8
3 files changed
+105 -7

No files matched your search

+58 -7
View File
@@ -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<f64> {
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<Vec<f64>> = 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<f64> = 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;
}
}
+14
View File
@@ -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<f64> {
let mut ys: Vec<f64> = 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
+33
View File
@@ -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<Line> {
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