mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 02:02:04 +01:00
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:
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user