Balance heading line wraps

This commit is contained in:
SGavrl committed 2026-07-31 01:55:25 +00:00
1 parent bae1259afe
commit 0ada0fe62d
4 files changed
+206 -4

No files matched your search

+7
View File
@@ -1396,6 +1396,7 @@ fn layout_dom_once(
opacity_product: f32, opacity_product: f32,
list_style: crate::ListStyle, list_style: crate::ListStyle,
line_height: crate::LineHeight, line_height: crate::LineHeight,
text_wrap_style: crate::TextWrapStyle,
text_transform: crate::TextTransform, text_transform: crate::TextTransform,
italic: bool, italic: bool,
box_sizing: crate::BoxSizing, box_sizing: crate::BoxSizing,
@@ -1433,6 +1434,7 @@ fn layout_dom_once(
// CSS initial value of list-style-type. // CSS initial value of list-style-type.
list_style: crate::ListStyle::Disc, list_style: crate::ListStyle::Disc,
line_height: crate::LineHeight::Normal, line_height: crate::LineHeight::Normal,
text_wrap_style: crate::TextWrapStyle::Auto,
text_transform: crate::TextTransform::None, text_transform: crate::TextTransform::None,
italic: false, italic: false,
box_sizing: crate::BoxSizing::ContentBox, box_sizing: crate::BoxSizing::ContentBox,
@@ -1764,6 +1766,7 @@ fn layout_dom_once(
style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02; style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02;
match style.list_style { Some(v) => inh.list_style = v, None => style.list_style = Some(inh.list_style) } match style.list_style { Some(v) => inh.list_style = v, None => style.list_style = Some(inh.list_style) }
match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) } match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) }
match style.text_wrap_style { Some(v) => inh.text_wrap_style = v, None => style.text_wrap_style = Some(inh.text_wrap_style) }
match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) } match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) }
match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) } match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) }
if style.box_sizing == crate::BoxSizing::Inherit { if style.box_sizing == crate::BoxSizing::Inherit {
@@ -1892,6 +1895,7 @@ fn layout_dom_once(
let host_optical_sizing = style.font_optical_sizing; let host_optical_sizing = style.font_optical_sizing;
let host_variation_settings = style.font_variation_settings.clone(); let host_variation_settings = style.font_variation_settings.clone();
let host_line_height = style.line_height; let host_line_height = style.line_height;
let host_text_wrap_style = style.text_wrap_style;
let host_transform = style.text_transform; let host_transform = style.text_transform;
let host_italic = style.font_style_italic; let host_italic = style.font_style_italic;
let host_text_align = style.text_align; let host_text_align = style.text_align;
@@ -2079,6 +2083,9 @@ fn layout_dom_once(
} else if pseudo.line_height.is_none() { } else if pseudo.line_height.is_none() {
pseudo.line_height = host_line_height; pseudo.line_height = host_line_height;
} }
if pseudo.text_wrap_style.is_none() {
pseudo.text_wrap_style = host_text_wrap_style;
}
if pseudo.color.is_none() { if pseudo.color.is_none() {
pseudo.color = host_color; pseudo.color = host_color;
} }
+155 -4
View File
@@ -278,6 +278,13 @@ fn metadata_variation(metadata: usize) -> Option<usize> {
/// paint it (filled in after layout). /// paint it (filled in after layout).
pub struct InlineItem { pub struct InlineItem {
buffer: Buffer, buffer: Buffer,
/// Whether final shaping should tighten the wrap width while preserving
/// the natural line count (`text-wrap-style: balance`).
balance_wrap: bool,
/// Alignment is applied against the original content width. Cosmic-text
/// uses its buffer width for both wrapping and alignment, so a balanced
/// (narrower) buffer needs a corresponding origin inset.
align: Option<Align>,
/// Minimum block-size contributed by explicit `<br>` breaks. cosmic-text /// Minimum block-size contributed by explicit `<br>` breaks. cosmic-text
/// omits the final empty run for a trailing newline, while CSS still gives /// omits the final empty run for a trailing newline, while CSS still gives
/// a break-only or consecutive-break line the parent's used line-height. /// a break-only or consecutive-break line the parent's used line-height.
@@ -1020,6 +1027,8 @@ impl TextEngine {
let idx = self.items.len(); let idx = self.items.len();
self.items.push(InlineItem { self.items.push(InlineItem {
buffer, buffer,
balance_wrap: base.text_wrap_style == Some(crate::TextWrapStyle::Balance),
align,
forced_min_height, forced_min_height,
origin: (0.0, 0.0), origin: (0.0, 0.0),
clip: None, clip: None,
@@ -1093,12 +1102,32 @@ impl TextEngine {
/// After layout, pin each context to its final content-box origin and /// After layout, pin each context to its final content-box origin and
/// clip, reshaping once at the resolved width so paint draws the same /// clip, reshaping once at the resolved width so paint draws the same
/// line breaks the box was sized for. /// line breaks the box was sized for.
pub fn finalize(&mut self, idx: usize, content_origin: (f32, f32), content_width: f32, clip: Option<Rect>) { pub fn finalize(
let TextEngine { font_system, items, .. } = self; &mut self,
idx: usize,
content_origin: (f32, f32),
content_width: f32,
clip: Option<Rect>,
) {
let TextEngine {
font_system, items, ..
} = self;
let item = &mut items[idx]; let item = &mut items[idx];
item.buffer.set_size(font_system, Some(content_width.max(0.0)), None); let content_width = content_width.max(0.0);
item.buffer.set_size(font_system, Some(content_width), None);
item.buffer.shape_until_scroll(font_system, false); item.buffer.shape_until_scroll(font_system, false);
item.origin = content_origin; let balanced_width = item
.balance_wrap
.then(|| balance_wrap_width(font_system, &mut item.buffer, content_width))
.flatten()
.unwrap_or(content_width);
let alignment_inset = (content_width - balanced_width).max(0.0)
* match item.align {
Some(Align::Center) => 0.5,
Some(Align::End | Align::Right) => 1.0,
_ => 0.0,
};
item.origin = (content_origin.0 + alignment_inset, content_origin.1);
item.clip = clip; item.clip = clip;
} }
} }
@@ -1483,6 +1512,50 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) {
(w.ceil(), h) (w.ceil(), h)
} }
/// Chromium's bisection implementation only balances paragraphs of at most
/// six lines. Keeping the same cap bounds repeated shaping on long body copy
/// and confines this work to the heading-sized content the property targets.
const MAX_BALANCED_LINES: usize = 6;
/// Tighten `buffer` to the narrowest (within one CSS pixel) wrapping width
/// that retains its natural line count. This is deliberately a final-shaping
/// operation: intrinsic measurement and the block's used geometry remain
/// unchanged, while the line grouping changes.
fn balance_wrap_width(
font_system: &mut FontSystem,
buffer: &mut Buffer,
available_width: f32,
) -> Option<f32> {
let natural = buffer.layout_runs().collect::<Vec<_>>();
let line_count = natural.len();
if !(2..=MAX_BALANCED_LINES).contains(&line_count) {
return None;
}
// Blink's bisection path is inapplicable to forced breaks; balancing each
// hard-break-delimited paragraph needs separate segment constraints.
if buffer.lines.len() > 1 {
return None;
}
let average_line_width = natural.iter().map(|run| run.line_w).sum::<f32>() / line_count as f32;
drop(natural);
let mut lower = (average_line_width * 0.8).clamp(0.0, available_width);
let mut upper = available_width;
while lower + 1.0 < upper {
let middle = (lower + upper) * 0.5;
buffer.set_size(font_system, Some(middle), None);
buffer.shape_until_scroll(font_system, false);
if buffer.layout_runs().count() == line_count {
upper = middle;
} else {
lower = middle;
}
}
buffer.set_size(font_system, Some(upper), None);
buffer.shape_until_scroll(font_system, false);
Some(upper)
}
/// Does `id` establish an inline formatting context made purely of text and /// Does `id` establish an inline formatting context made purely of text and
/// plain inline formatting (no atomic inline boxes, no block children, no /// plain inline formatting (no atomic inline boxes, no block children, no
/// inline element carrying its own box)? Such a container collapses cleanly /// inline element carrying its own box)? Such a container collapses cleanly
@@ -2742,6 +2815,84 @@ gamma</div>"#,
); );
} }
fn shaped_line_texts(buffer: &Buffer) -> Vec<String> {
buffer
.layout_runs()
.map(|run| {
let start = run
.glyphs
.iter()
.map(|glyph| glyph.start)
.min()
.unwrap_or(0);
let end = run
.glyphs
.iter()
.map(|glyph| glyph.end)
.max()
.unwrap_or(start);
run.text[start..end].trim().to_string()
})
.collect()
}
#[test]
fn text_wrap_balance_changes_line_grouping_without_changing_line_count() {
let tree = obscura_dom::parse_html("<h1 id='hero'>Welcome to Mozilla</h1>");
let hero = tree.get_element_by_id("hero").unwrap();
let style = LayoutStyle {
display: Display::Block,
font_size: Some(128.0),
line_height: Some(crate::LineHeight::Px(128.0)),
text_wrap_style: Some(crate::TextWrapStyle::Balance),
..Default::default()
};
let mut engine = TextEngine::new();
let item = engine
.try_build(&tree, hero, &HashMap::from([(hero, style)]))
.unwrap();
let width = 912.0;
engine.measure(item, Some(width));
let natural_lines = shaped_line_texts(&engine.items[item].buffer);
engine.finalize(item, (0.0, 0.0), width, None);
let balanced_lines = shaped_line_texts(&engine.items[item].buffer);
let balanced_width = engine.items[item].buffer.size().0.unwrap();
assert_eq!(natural_lines, ["Welcome to", "Mozilla"]);
assert_eq!(balanced_lines, ["Welcome", "to Mozilla"]);
assert!(
balanced_width < width - 1.0,
"balance should tighten the effective wrap width: {balanced_width}"
);
}
#[test]
fn text_wrap_style_is_inherited_and_can_be_reset() {
let tree = obscura_dom::parse_html(
r#"<style>
#outer { text-wrap:balance }
#reset { text-wrap-style:auto }
</style>
<div id="outer">
<h1 id="inherited">balanced heading words</h1>
<h1 id="reset">ordinary heading words</h1>
</div>"#,
);
let inherited = tree.get_element_by_id("inherited").unwrap();
let reset = tree.get_element_by_id("reset").unwrap();
let laid = crate::dom::layout_dom(&tree, (500.0, 300.0));
assert_eq!(
laid.styles[&inherited].text_wrap_style,
Some(crate::TextWrapStyle::Balance)
);
assert_eq!(
laid.styles[&reset].text_wrap_style,
Some(crate::TextWrapStyle::Auto)
);
}
#[test] #[test]
fn clip_fill_only_for_transparent_clip_text() { fn clip_fill_only_for_transparent_clip_text() {
let mut s = LayoutStyle::default(); let mut s = LayoutStyle::default();
+15
View File
@@ -619,6 +619,11 @@ pub struct LayoutStyle {
/// value (or the initial `normal`). The inline shaper uses this to retain /// value (or the initial `normal`). The inline shaper uses this to retain
/// author/source newlines in code blocks and to select wrapping behavior. /// author/source newlines in code blocks and to select wrapping behavior.
pub white_space: Option<WhiteSpace>, pub white_space: Option<WhiteSpace>,
/// `text-wrap-style`. Inherited; `None` means inherit the nearest
/// ancestor's value (or the initial `auto`). `Balance` keeps the natural
/// line count but tightens the effective line-breaking width during the
/// inline formatter's final shaping pass.
pub text_wrap_style: Option<TextWrapStyle>,
/// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved /// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved
/// after the element font and live viewport are known. /// after the element font and live viewport are known.
pub line_height_expression: Option<String>, pub line_height_expression: Option<String>,
@@ -804,6 +809,16 @@ pub enum WhiteSpace {
BreakSpaces, BreakSpaces,
} }
/// The implemented `text-wrap-style` values. Other line-breaking strategies
/// such as `pretty` remain unsupported until their distinct scoring model is
/// available; treating them as `auto` would make `@supports` lie.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextWrapStyle {
#[default]
Auto,
Balance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextTransform { pub enum TextTransform {
None, None,
+29
View File
@@ -1213,6 +1213,28 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
_ => style.white_space, _ => style.white_space,
}; };
} }
"text-wrap" => {
style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() {
"auto" | "wrap" | "initial" | "revert" | "revert-layer" => {
Some(crate::TextWrapStyle::Auto)
}
"balance" | "wrap balance" | "balance wrap" => Some(crate::TextWrapStyle::Balance),
// `text-wrap` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.text_wrap_style,
};
}
"text-wrap-style" => {
style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() {
"auto" | "initial" | "revert" | "revert-layer" => {
Some(crate::TextWrapStyle::Auto)
}
"balance" => Some(crate::TextWrapStyle::Balance),
// `text-wrap-style` inherits, so unset behaves as inherit.
"inherit" | "unset" => None,
_ => style.text_wrap_style,
};
}
"font-style" => { "font-style" => {
let v = value.trim().to_ascii_lowercase(); let v = value.trim().to_ascii_lowercase();
style.font_style_italic = Some(v.starts_with("italic") || v.starts_with("oblique")); style.font_style_italic = Some(v.starts_with("italic") || v.starts_with("oblique"));
@@ -1454,6 +1476,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "text-decoration-line" | "text-decoration-line"
| "line-height" | "line-height"
| "white-space" | "white-space"
| "text-wrap"
| "text-wrap-style"
| "align-items" | "align-items"
| "justify-items" | "justify-items"
| "place-items" | "place-items"
@@ -1577,6 +1601,11 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
value.to_ascii_lowercase().as_str(), value.to_ascii_lowercase().as_str(),
"normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces" "normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces"
), ),
"text-wrap" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
),
"text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"),
"float" => matches!( "float" => matches!(
value.to_ascii_lowercase().as_str(), value.to_ascii_lowercase().as_str(),
"none" | "left" | "right" "none" | "left" | "right"