diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 19d7b8c72d..ae1bd552af 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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| { 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 } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7afb4d6cd1..894ecf4e4f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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::>(); + 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();