fix(text): break paragraph lines at the horizontally scaled width (#5611)

Horizontal scale was applied after line breaking, so paragraph text below
100% wrapped early and text above 100% overflowed the box. Break box text
at the available width divided by the paragraph's horizontal scale (the
largest one when runs mix), scale the first-line indent and alignment
offset back to the box, and keep the scaled trailing space out of the
line extent.

Fixes storytold/photocraft#5565

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-16 19:19:59 -06:00
committed by GitHub
co-authored by Cursor
parent d213273c74
commit a1e19a0b87
2 changed files with 79 additions and 7 deletions
+41 -7
View File
@@ -442,6 +442,8 @@ struct KernSlot {
blank: bool,
/// Advance along the line (px, horizontal scale included).
advance: f32,
/// What horizontal scale adds to the shaped advance (px; negative below 100%).
growth: f32,
/// A tab, shaped as a space; it advances to the next tab stop.
tab: bool,
/// Vertical type, upright glyph (its outline doesn't run along the column).
@@ -646,11 +648,29 @@ impl Layouter {
// Box extent along the lines: width, or height for vertical type (columns).
let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) };
let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None };
// Horizontal scale is applied after shaping, so paragraph text breaks at the box
// width divided by it (#5565). Mixed scales break at the largest, so no line
// overflows the box.
let break_scale = if is_box {
let si0 = style_at(prange.start);
(0..out.styles.len())
.filter(|&ri| {
let rs = run_starts.get(ri).copied().unwrap_or(0);
let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len));
ri == si0 || (rs.max(prange.start) < end.min(content_end))
})
.filter_map(|ri| out.styles.get(ri))
.map(effective_horizontal_scale)
.fold(0.0f32, f32::max)
} else {
1.0
};
let break_scale = if break_scale > 0.0 { break_scale } else { 1.0 };
let break_lines = |layout: &mut Layout<RunBrush>| {
if first_indent != 0.0 {
layout.set_text_indent(first_indent, IndentOptions::default());
layout.set_text_indent(first_indent / break_scale, IndentOptions::default());
}
layout.break_all_lines(avail);
layout.break_all_lines(avail.map(|a| a / break_scale));
};
// Paragraph text: a tab is never wider than a space plus one tab interval, so breaking
// lines at that width keeps the expanded tabs inside the box, but can wrap early.
@@ -779,6 +799,7 @@ impl Layouter {
blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(),
upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8,
advance: c.advance() * hs,
growth: c.advance() * (hs - 1.0),
tab: tabs.binary_search(&c.text_range().start).is_ok(),
});
}
@@ -823,10 +844,15 @@ impl Layouter {
// moves the whole line).
let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 };
let mut trailing_tabs = 0.0f32;
let mut trailing_growth = 0.0f32;
for (j, s) in slots.iter().enumerate() {
// Trailing whitespace is outside the line's extent (`adv`).
let trailing = s.blank && slots[j + 1..].iter().all(|n| n.blank);
if trailing {
trailing_growth += s.growth;
}
if s.tab {
kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance;
// Trailing whitespace is outside the line's extent (`adv`).
if slots[j + 1..].iter().all(|n| n.blank) {
trailing_tabs += kern_px[j];
}
@@ -864,15 +890,18 @@ impl Layouter {
};
let dx = if is_box {
let base = line_origin + indent_start - kern_align;
let slack = avail.unwrap_or(0.0) - adv - line_kern;
let slack = avail.unwrap_or(0.0) - adv * break_scale - line_kern;
// Parley aligned the unscaled line in `avail / break_scale`: scale its
// indent and alignment offset back to the box.
let scaled_offset = m.offset * (break_scale - 1.0);
base + if last_line {
match ps.align {
TextAlign::JustifyCenter => slack * 0.5 - m.offset,
TextAlign::JustifyRight => slack - m.offset,
_ => 0.0,
_ => scaled_offset,
}
} else {
0.0
scaled_offset
}
} else {
let target = match ps.align {
@@ -1037,7 +1066,7 @@ impl Layouter {
range: map(lr.start)..map(lr.end).min(content_end),
baseline,
x0,
x1: x0 + adv + extra - trailing_tabs,
x1: x0 + adv + extra - trailing_tabs - trailing_growth,
ascent,
descent,
paragraph: pi,
@@ -1049,6 +1078,11 @@ impl Layouter {
}
}
/// A style's horizontal scale; unset (zero or negative) is 100%.
fn effective_horizontal_scale(st: &CharStyle) -> f32 {
if st.horizontal_scale > 0.0 && st.horizontal_scale.is_finite() { st.horizontal_scale } else { 1.0 }
}
/// The first tab stop after `pos` (px from the anchor or box edge); stops are `interval` apart.
fn next_tab_stop(pos: f32, interval: f32) -> f32 {
if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos }
+38
View File
@@ -272,6 +272,44 @@ fn box_text_wraps_inside_width() {
assert_eq!(l2.lines.len(), 2);
}
/// #5565: paragraph text breaks at its horizontally scaled width. At 60% it breaks like 100%
/// text in a box 1/0.6 as wide, and the lines fill the box instead of wrapping early.
#[test]
fn horizontal_scale_breaks_paragraph_lines_at_the_scaled_width() {
let mut e = TextEngine::new();
let text = "The quick brown fox jumps over the lazy dog again and again until the paragraph wraps";
let boxed = |scale: f32, width: f32, align: TextAlign| {
let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: scale, ..Default::default() };
let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 0.0, width, height: 1000.0 }, ..styled(text, style) };
with_para(t, ParagraphStyle { align, ..Default::default() })
};
let ranges = |l: &crate::TextLayout| l.lines.iter().map(|ln| ln.range.clone()).collect::<Vec<_>>();
for scale in [0.6f32, 1.5] {
let scaled = e.layout(&boxed(scale, 120.0, TextAlign::Left), 72.0);
let plain = e.layout(&boxed(1.0, 120.0 / scale, TextAlign::Left), 72.0);
assert!(scaled.lines.len() > 1, "{scale}");
assert_eq!(ranges(&scaled), ranges(&plain), "{scale}: same breaks as unscaled text in the widened box");
for (li, ln) in scaled.lines.iter().enumerate() {
assert!(ln.x0 >= 10.0 - 1e-3 && ln.x1 <= 130.0 + 1e-3, "{scale}: {ln:?}");
// The extent ends at the last glyph, not after the scaled trailing space.
let ink = scaled.clusters.iter().filter(|c| c.line == li && !text[c.range.clone()].trim().is_empty());
assert!((ink.map(|c| c.x + c.advance).fold(0.0, f32::max) - ln.x1).abs() < 0.01, "{scale}: {ln:?}");
}
}
// At 60% the lines use the box: the first would not fit at 100%.
let scaled = e.layout(&boxed(0.6, 120.0, TextAlign::Left), 72.0);
assert!(scaled.lines[0].x1 > 110.0, "{:?}", scaled.lines[0]);
// Centred and right-aligned lines are placed by their scaled width.
let c = e.layout(&boxed(0.6, 120.0, TextAlign::Center), 72.0);
for ln in &c.lines {
assert!(((ln.x0 + ln.x1) * 0.5 - 70.0).abs() < 0.5, "{ln:?}");
}
let r = e.layout(&boxed(0.6, 120.0, TextAlign::Right), 72.0);
for ln in &r.lines {
assert!((ln.x1 - 130.0).abs() < 0.5, "{ln:?}");
}
}
#[test]
fn alignment_point_and_box() {
let mut e = TextEngine::new();