fix(layout): exclude collapsed line-end spaces from overflow

This commit is contained in:
kevin
2026-10-08 11:34:21 +08:00
committed by lanyue-llk
parent 8168e068c3
commit f9b3ce7897
2 changed files with 130 additions and 2 deletions
+65 -2
View File
@@ -576,10 +576,28 @@ pub(crate) fn build_inline_fragments(
for (line_index, line) in layout.lines().enumerate() {
let metrics = line.metrics();
let line_range = line.text_range();
let trailing_start = overlapping_output_ranges(&context.text_units, &line_range)
.iter()
.rev()
.take_while(|unit| unit.control || unit.collapsible_whitespace)
.filter(|unit| unit.collapsible_whitespace)
.map(|unit| unit.output_range.start)
.last();
let mut collapsed_trailing_advance = 0.0;
if let Some(start) = trailing_start {
for run in line.runs() {
for cluster in run.visual_clusters() {
if cluster.text_range().start >= start {
collapsed_trailing_advance += cluster.advance().max(0.0);
}
}
}
}
let placement = line_placements
.get(line_index)
.filter(|placement| placement.line_index == line_index);
let line_rect = placement.map_or_else(
let mut line_rect = placement.map_or_else(
|| {
PaintRect::new(
metrics.inline_min_coord + metrics.offset,
@@ -590,6 +608,14 @@ pub(crate) fn build_inline_fragments(
},
|placement| placement.rect,
);
// Parley retains hanging spaces for shaping/line breaking. CSS
// collapsed line-end spaces contribute neither range/inline-box
// geometry nor scrollable overflow. Do not trim preserved or NBSP
// advances merely because Parley classifies them as whitespace.
line_rect.width = (line_rect.width - collapsed_trailing_advance).max(0.0);
if layout.is_rtl() {
line_rect.x += collapsed_trailing_advance;
}
fragments.lines.push(InlineLineFragment {
line_index,
rect: line_rect,
@@ -614,6 +640,9 @@ pub(crate) fn build_inline_fragments(
let run_metrics = run.font_metrics();
for cluster in run.visual_clusters() {
let range = cluster.text_range();
if trailing_start.is_some_and(|start| range.start >= start) {
continue;
}
let style_index = usize::from(cluster.style_index());
let vertical_offset = placement.map_or(0.0, |placement| {
placement.glyph_offset(run.index(), style_index)
@@ -3415,6 +3444,40 @@ mod tests {
normalizer.finish()
}
#[test]
fn only_collapsible_whitespaces_are_marked_for_line_end_removal() {
let text = LayoutBoxId::from_index(1);
for mode in [
InlineWhiteSpaceCollapse::Collapse,
InlineWhiteSpaceCollapse::PreserveBreaks,
InlineWhiteSpaceCollapse::Preserve,
InlineWhiteSpaceCollapse::BreakSpaces,
] {
let input = normalize(&[(text, "A \u{a0} B")], mode, InlineTextTransform::None);
let collapsed = input
.units
.iter()
.filter(|unit| unit.collapsible_whitespace)
.collect::<Vec<_>>();
assert_eq!(
collapsed.len(),
if matches!(
mode,
InlineWhiteSpaceCollapse::Collapse | InlineWhiteSpaceCollapse::PreserveBreaks
) {
2
} else {
0
}
);
assert!(
collapsed
.iter()
.all(|unit| &input.text[unit.output_range.clone()] == " ")
);
}
}
#[test]
fn preserve_merges_crlf_across_adjacent_text_nodes_with_both_origins() {
let first = LayoutBoxId::from_index(1);
@@ -3510,7 +3573,7 @@ mod tests {
}
#[test]
fn collapsed_spaces_remain_in_dom_order_across_inline_boundaries() {
fn collapsible_whitespaces_remain_in_dom_order_across_inline_boundaries() {
let root = LayoutBoxId::from_index(0);
let first_inline = LayoutBoxId::from_index(1);
let first_text = LayoutBoxId::from_index(2);
@@ -4349,3 +4349,68 @@ fn transformed_fixed_containing_block_still_clips_its_fixed_descendant() {
"the fixed containing block's overflow clip must still apply"
);
}
#[test]
fn inline_scroll_overflow_keeps_unbreakable_text_and_nbsp() {
for text in ["abcdefghijklmno", "alpha\u{a0}beta\u{a0}gamma"] {
let source = Source(vec![
Node::element("root", vec![1]),
Node::element("scroller", vec![2]),
Node::element("fixed-font", vec![3]),
Node::text("text", text),
]);
let (font_style, mut services) = fixed_inline_font();
let mut styles = Styles::default();
styles
.0
.insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0));
styles.0.insert(
1,
resolved(
LayoutDisplay::Block,
Style {
size: Size {
width: length(85.0),
height: length(40.0),
},
overflow: Point {
x: Overflow::Scroll,
y: Overflow::Scroll,
},
..Style::default()
},
),
);
styles.0.insert(2, font_style);
let output = build_layout_pass(
&source,
&mut styles,
&mut services,
LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test),
)
.unwrap();
let scroller = output.source_output(1).unwrap().principal_box.unwrap();
assert!(
output
.scroll_extent(scroller)
.unwrap()
.horizontal_scrollbar
.is_some(),
"{text}"
);
assert!(
output
.element_metrics_for_source(1)
.unwrap()
.scroll_size
.width
> 85.0,
"{text}"
);
assert!(
!output
.text_range_rects(3, 0..text.encode_utf16().count())
.is_empty()
);
}
}