⚡ Lay out a window of the paragraph to find squeeze sheds

Finding the line-end squeeze laid out the whole paragraph again for every
line, so the cost grew with the square of the length and typing in a
long Japanese text lagged.

Lines break greedily, so a line depends only on the text from its start.
Lay out a window from the line start that holds the line and the next
one, doubling it when needed, keep it while no squeeze changes the sheds,
and lay out the full paragraph once at the end. A test checks the sheds
match a full relayout.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-11-03 17:40:13 +01:00
parent dffb8a9240
commit f8ca373f28
4 changed files with 346 additions and 32 deletions

View File

@ -744,11 +744,12 @@ impl TextContent {
plan: &HorizontalParagraphPlan,
width: f32,
) -> HorizontalAdjustment {
let lay_out = |extra_sheds: &[Vec<(usize, f32)>]| {
let lay_out = |extra_sheds: &[Vec<(usize, f32)>], window: std::ops::Range<usize>| {
let mut laid_out = LayoutParagraph::build(&mut self.horizontal_measure_builder(
paragraph,
plan,
extra_sheds,
window,
));
laid_out.layout(plan.layout_width(width));
laid_out
@ -760,7 +761,7 @@ impl TextContent {
horizontal_squeeze_sheds(paragraph, plan, width, &lay_out)
} else {
let sheds = vec![Vec::new(); plan.texts.len()];
let laid_out = lay_out(&sheds);
let laid_out = lay_out(&sheds, FULL_WINDOW);
(sheds, laid_out)
};
let spacing = if paragraph.text_align() == TextAlign::Distribute {
@ -777,13 +778,15 @@ impl TextContent {
HorizontalAdjustment { sheds, spacing }
}
/// Left-aligned builder of one paragraph with plain fills, its plan's
/// sheds plus `extra_sheds`, for measuring line adjustment.
/// Left-aligned builder of the builder UTF-16 `window` of one paragraph
/// (`FULL_WINDOW` for all of it) with plain fills, its plan's sheds plus
/// `extra_sheds`, for measuring line adjustment.
fn horizontal_measure_builder(
&self,
paragraph: &Paragraph,
plan: &HorizontalParagraphPlan,
extra_sheds: &[Vec<(usize, f32)>],
window: std::ops::Range<usize>,
) -> HorizontalBuilder {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@ -803,6 +806,23 @@ impl TextContent {
.chain(extra_sheds.get(span_index).into_iter().flatten())
.copied()
.collect();
let ruby = &plan.ruby_spacing.adjustments[span_index];
let (text, combined, ruby) = if window == FULL_WINDOW {
(text.as_str(), combined, ruby.clone())
} else {
let Some(range) = plan.offsets.ranges.iter().find(|r| r.span == span_index) else {
continue;
};
let Some((slice, from)) = window_slice(text, range, &window) else {
continue;
};
let len = slice.chars().count();
(
slice,
window_entries(&combined, from, len),
window_entries(ruby, from, len),
)
};
let text_style = span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
@ -816,7 +836,7 @@ impl TextContent {
span,
text,
&combined,
&plan.ruby_spacing.adjustments[span_index],
&ruby,
&text_style,
fonts,
);
@ -845,17 +865,25 @@ impl TextContent {
let mut plan = HorizontalParagraphPlan::new(paragraph);
plan.relax_overlong_words(paragraph, width, |plan| {
let sheds = vec![Vec::new(); plan.texts.len()];
let mut measured = LayoutParagraph::build(
&mut self.horizontal_measure_builder(paragraph, plan, &sheds),
);
let mut measured =
LayoutParagraph::build(&mut self.horizontal_measure_builder(
paragraph,
plan,
&sheds,
FULL_WINDOW,
));
measured.layout(width);
measured
});
plan.retain_atomic_overflow(paragraph, width, |plan, width| {
let sheds = vec![Vec::new(); plan.texts.len()];
let mut measured = LayoutParagraph::build(
&mut self.horizontal_measure_builder(paragraph, plan, &sheds),
);
let mut measured =
LayoutParagraph::build(&mut self.horizontal_measure_builder(
paragraph,
plan,
&sheds,
FULL_WINDOW,
));
measured.layout(width);
measured
});

View File

@ -173,7 +173,14 @@ pub(crate) fn horizontal_policy_justify_spacing(
}
/// Lays a paragraph out with extra per-span sheds (char index, amount).
pub(crate) type LayOutWithSheds<'a> = dyn Fn(&[Vec<(usize, f32)>]) -> LayoutParagraph + 'a;
/// Lays out a paragraph with extra sheds, restricted to a builder UTF-16
/// window (`FULL_WINDOW` for all of it).
pub(crate) type LayOutWithSheds<'a> =
dyn Fn(&[Vec<(usize, f32)>], std::ops::Range<usize>) -> LayoutParagraph + 'a;
/// Builder UTF-16 units first laid out from a line start: a few lines of
/// text. The window doubles until it holds the lines a decision needs.
const SQUEEZE_WINDOW: usize = 512;
/// One real character of a horizontal paragraph in builder order.
struct LineChar {
@ -270,22 +277,71 @@ pub(crate) fn horizontal_squeeze_sheds(
) -> (Vec<Vec<(usize, f32)>>, LayoutParagraph) {
let chars = line_chars(paragraph, plan);
let mut sheds: Vec<Vec<(usize, f32)>> = vec![Vec::new(); plan.texts.len()];
let mut laid_out = lay_out(&sheds);
let total = plan
.offsets
.ranges
.iter()
.map(|range| range.builder_end)
.max()
.unwrap_or(0);
// Lines are broken greedily, so a line and the decision about it depend
// only on the text from its start. Lay out that text alone, enough of it
// for the line, the next one and what decides the next break: relaying
// out the whole paragraph per line made long paragraphs quadratic.
let lay_out_from = |sheds: &[Vec<(usize, f32)>], start: usize| {
let mut size = SQUEEZE_WINDOW;
loop {
let end = start.saturating_add(size).min(total);
let laid_out = lay_out(sheds, start..end);
if end >= total || laid_out.get_line_metrics().len() >= 3 {
return laid_out;
}
size *= 2;
}
};
// `window` is laid out from `window_start` with the current sheds; its
// lines stay valid until a squeeze changes the sheds, so lines without a
// candidate move on inside it instead of laying out again.
let mut window_start = 0usize;
let mut window = lay_out_from(&sheds, window_start);
let mut line_index = 0usize;
for _ in 0..chars.len() + 1 {
let lines = laid_out.get_line_metrics();
if line_index + 1 >= lines.len() {
let window_lines = window.get_line_metrics();
// A line is decided by the next one and what follows it: past that,
// lay out again from the line.
let reached_end = window_lines
.last()
.is_some_and(|last| window_start + last.end_index >= total);
if line_index > 0 && line_index + 2 >= window_lines.len() && !reached_end {
window_start += window_lines.get(line_index).map_or_else(
|| window_lines.last().map_or(0, |last| last.end_index),
|line| line.start_index,
);
window = lay_out_from(&sheds, window_start);
line_index = 0;
continue;
}
let start = window_start
+ window_lines
.get(line_index)
.map_or(0, |line| line.start_index);
let laid_out = &window;
let lines: Vec<_> = window_lines.iter().skip(line_index).collect();
if lines.len() < 2 {
break;
}
let (line, next) = (&lines[line_index], &lines[line_index + 1]);
let Some(unit_start) = chars.iter().position(|c| c.utf16 >= next.start_index) else {
let (line, next) = (lines[0], lines[1]);
let next_start = window_start + next.start_index;
let Some(unit_start) = chars.iter().position(|c| c.utf16 >= next_start) else {
break;
};
let unit = line_start_unit(&chars, unit_start);
let unit_end_utf16 = chars.get(unit.end).map_or(next.end_index, |c| c.utf16);
let unit_end_utf16 = chars
.get(unit.end)
.map_or(window_start + next.end_index, |c| c.utf16);
let unit_width = laid_out
.get_rects_for_range(
chars[unit.start].utf16..unit_end_utf16,
chars[unit.start].utf16 - window_start..unit_end_utf16 - window_start,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
)
@ -301,11 +357,11 @@ pub(crate) fn horizontal_squeeze_sheds(
let line_chars_end = unit.start;
let line_chars_start = chars
.iter()
.position(|c| c.utf16 >= line.start_index)
.position(|c| c.utf16 >= start)
.unwrap_or(line_chars_end);
let capacities = squeeze_capacities(&chars, line_chars_start..line_chars_end);
let total: f32 = capacities.iter().map(|(_, _, cap)| cap).sum();
if needed <= 0.0 || total < needed {
let capacity: f32 = capacities.iter().map(|(_, _, cap)| cap).sum();
if needed <= 0.0 || capacity < needed {
line_index += 1;
continue;
}
@ -328,16 +384,194 @@ pub(crate) fn horizontal_squeeze_sheds(
if trailing_aki > 0.0 {
candidate[last.span].push((last.index, trailing_aki));
}
let squeezed = lay_out(&candidate);
let kept = squeezed
.get_line_metrics()
.get(line_index)
.is_some_and(|squeezed_line| squeezed_line.end_index >= unit_end_utf16);
// The check needs this line only: lay out to the end of the pulled
// unit plus a line's worth of what follows.
let check_end = unit_end_utf16
.saturating_add(unit_end_utf16 - start)
.min(total);
let squeezed = lay_out(&candidate, start..check_end);
let squeezed_lines = squeezed.get_line_metrics();
let kept = squeezed_lines
.first()
.is_some_and(|squeezed_line| start + squeezed_line.end_index >= unit_end_utf16);
if kept {
sheds = candidate;
laid_out = squeezed;
if check_end >= total && squeezed_lines.len() < 2 {
break;
}
window_start = start;
window = squeezed;
line_index = 1;
} else {
line_index += 1;
}
line_index += 1;
}
let laid_out = lay_out(&sheds, FULL_WINDOW);
(sheds, laid_out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{TextAlign, TextDirection};
use skia::textlayout::{FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle};
use std::cell::Cell;
type LayOutFull<'a> = dyn Fn(&[Vec<(usize, f32)>]) -> LayoutParagraph + 'a;
/// The squeeze pass as it was before windowing: one layout of the whole
/// paragraph per line.
fn full_relayout_sheds(
paragraph: &Paragraph,
plan: &HorizontalParagraphPlan,
width: f32,
lay_out: &LayOutFull,
) -> Vec<Vec<(usize, f32)>> {
let chars = line_chars(paragraph, plan);
let mut sheds: Vec<Vec<(usize, f32)>> = vec![Vec::new(); plan.texts.len()];
let mut laid_out = lay_out(&sheds);
let mut line_index = 0usize;
for _ in 0..chars.len() + 1 {
let lines = laid_out.get_line_metrics();
if line_index + 1 >= lines.len() {
break;
}
let (line, next) = (&lines[line_index], &lines[line_index + 1]);
let Some(unit_start) = chars.iter().position(|c| c.utf16 >= next.start_index) else {
break;
};
let unit = line_start_unit(&chars, unit_start);
let unit_end_utf16 = chars.get(unit.end).map_or(next.end_index, |c| c.utf16);
let unit_width = laid_out
.get_rects_for_range(
chars[unit.start].utf16..unit_end_utf16,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
)
.iter()
.map(|rect| rect.rect.width())
.sum::<f32>();
let last = &chars[unit.end - 1];
let trailing_aki = match last.class {
Some(JapaneseClass::ClosingBracket | JapaneseClass::Comma) => last.font_size / 2.0,
_ => 0.0,
};
let needed = line.width as f32 + unit_width - trailing_aki - width + JUSTIFY_EPSILON;
let line_chars_end = unit.start;
let line_chars_start = chars
.iter()
.position(|c| c.utf16 >= line.start_index)
.unwrap_or(line_chars_end);
let capacities = squeeze_capacities(&chars, line_chars_start..line_chars_end);
let total: f32 = capacities.iter().map(|(_, _, cap)| cap).sum();
if needed <= 0.0 || total < needed {
line_index += 1;
continue;
}
let mut candidate = sheds.clone();
let mut remaining = needed;
for priority in 1..=5 {
let caps: Vec<(usize, f32)> = capacities
.iter()
.filter(|(_, p, _)| *p == priority)
.map(|(at, _, cap)| (*at, *cap))
.collect();
for (at, amount) in capped_equal_allocations(&caps, remaining) {
candidate[chars[at].span].push((chars[at].index, amount));
remaining -= amount;
}
if remaining <= 0.0 {
break;
}
}
if trailing_aki > 0.0 {
candidate[last.span].push((last.index, trailing_aki));
}
let squeezed = lay_out(&candidate);
if squeezed
.get_line_metrics()
.get(line_index)
.is_some_and(|squeezed_line| squeezed_line.end_index >= unit_end_utf16)
{
sheds = candidate;
laid_out = squeezed;
}
line_index += 1;
}
sheds
}
#[test]
fn windowed_squeeze_matches_full_relayout_and_lays_out_the_paragraph_once() {
crate::globals::design_init();
let text = "あい、うえ、おかきくけこさしすせそ「かぎ」、と。".repeat(40);
let span = TextSpan {
text,
font_size: 16.0,
line_height: 1.0,
..TextSpan::default()
};
let mut paragraph = Paragraph::new(
TextAlign::default(),
TextDirection::LTR,
None,
None,
1.0,
0.0,
vec![span],
);
paragraph.set_language("ja".to_string());
let plan = HorizontalParagraphPlan::new(&paragraph);
let mut style = TextStyle::default();
style.set_font_size(16.0);
let mut fonts = FontCollection::new();
fonts.set_default_font_manager(skia::FontMgr::new(), None);
let mut paragraph_style = ParagraphStyle::default();
paragraph_style.set_apply_rounding_hack(false);
let width = 16.0 * 7.6;
let full_layouts = Cell::new(0usize);
let lay_out = |extra: &[Vec<(usize, f32)>], window: std::ops::Range<usize>| {
if window == FULL_WINDOW {
full_layouts.set(full_layouts.get() + 1);
}
let sheds: Vec<(usize, f32)> = plan.sheds[0]
.iter()
.chain(extra.first().into_iter().flatten())
.copied()
.collect();
let (text, from) =
window_slice(&plan.texts[0], &plan.offsets.ranges[0], &window).unwrap_or(("", 0));
let sheds = window_entries(&sheds, from, text.chars().count());
let mut builder = ParagraphBuilder::new(&paragraph_style, &fonts);
builder.push_style(&style);
add_horizontal_span(
&mut builder,
&paragraph.children()[0],
text,
&sheds,
&[],
&style,
&fonts,
);
let mut laid_out = LayoutParagraph::build(&mut builder);
laid_out.layout(width);
laid_out
};
let (windowed, _) = horizontal_squeeze_sheds(&paragraph, &plan, width, &lay_out);
assert_eq!(
full_layouts.get(),
1,
"the whole paragraph is laid out once"
);
let reference = full_relayout_sheds(&paragraph, &plan, width, &|extra| {
lay_out(extra, FULL_WINDOW)
});
assert!(!reference[0].is_empty(), "the text squeezes some lines");
assert_eq!(windowed.len(), reference.len());
for (got, want) in windowed[0].iter().zip(&reference[0]) {
assert_eq!(got.0, want.0);
assert!((got.1 - want.1).abs() < 0.01, "{got:?} vs {want:?}");
}
assert_eq!(windowed[0].len(), reference[0].len());
}
}

View File

@ -211,6 +211,55 @@ pub(crate) fn add_horizontal_span(
}
}
/// Builder UTF-16 window that covers a whole paragraph.
pub(crate) const FULL_WINDOW: std::ops::Range<usize> = 0..usize::MAX;
/// Part of a span's builder `text` inside the builder UTF-16 `window`, with
/// the char index of its first character, or None when the span lies
/// outside the window. A warichu box is atomic: kept whole when it starts
/// inside the window.
pub(crate) fn window_slice<'a>(
text: &'a str,
range: &HorizontalSpanRange,
window: &std::ops::Range<usize>,
) -> Option<(&'a str, usize)> {
if range.builder_end <= window.start || range.builder_start >= window.end {
return None;
}
if range.warichu {
return (range.builder_start >= window.start).then_some((text, 0));
}
let mut utf16 = range.builder_start;
let mut from = None;
let mut to = text.len();
for (index, (byte, ch)) in text.char_indices().enumerate() {
if from.is_none() && utf16 >= window.start {
from = Some((byte, index));
}
if utf16 >= window.end {
to = byte;
break;
}
utf16 += ch.len_utf16();
}
let (from_byte, from_char) = from?;
Some((&text[from_byte..to.max(from_byte)], from_char))
}
/// Per-char `entries` of a span shifted to a slice starting at char `from`
/// and `len` chars long.
pub(crate) fn window_entries(
entries: &[(usize, f32)],
from: usize,
len: usize,
) -> Vec<(usize, f32)> {
entries
.iter()
.filter(|(index, _)| *index >= from && *index < from + len)
.map(|(index, amount)| (index - from, *amount))
.collect()
}
/// Add `text`, adjusting the letter-spacing of single characters so the
/// builder text stays unchanged: those at `sheds` lose their amount and
/// those in `extra` take its added spacing. Skia tracks every cluster,

View File

@ -1773,18 +1773,21 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() {
};
// Seven characters fit; the eighth overflows by less than the 、 aki.
let width = advance * 7.6;
let lay_out = |extra: &[Vec<(usize, f32)>]| {
let lay_out = |extra: &[Vec<(usize, f32)>], window: std::ops::Range<usize>| {
let sheds: Vec<(usize, f32)> = plan.sheds[0]
.iter()
.chain(extra.first().into_iter().flatten())
.copied()
.collect();
let (text, from) =
window_slice(&plan.texts[0], &plan.offsets.ranges[0], &window).unwrap_or(("", 0));
let sheds = window_entries(&sheds, from, text.chars().count());
let mut builder = ParagraphBuilder::new(&paragraph_style, &fonts);
builder.push_style(&style);
add_horizontal_span(
&mut builder,
&paragraph.children()[0],
&plan.texts[0],
text,
&sheds,
&[],
&style,
@ -1797,7 +1800,7 @@ fn horizontal_squeeze_keeps_the_next_character_on_the_line() {
// Line ends count builder UTF-16 units, word joiners included.
let builder_text: Vec<char> = plan.texts[0].chars().collect();
let natural = lay_out(&[]);
let natural = lay_out(&[], FULL_WINDOW);
let natural_end = natural.get_line_metrics()[0].end_index;
assert_eq!(
builder_text[natural_end], 'か',