From 0ada0fe62d237986470fb52d9f8a7e15af0bebe2 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:55:25 +0000 Subject: [PATCH] Balance heading line wraps --- crates/obscura-render/src/dom.rs | 7 ++ crates/obscura-render/src/inline.rs | 159 +++++++++++++++++++++++++++- crates/obscura-render/src/lib.rs | 15 +++ crates/obscura-render/src/style.rs | 29 +++++ 4 files changed, 206 insertions(+), 4 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 346bace..bb16e11 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -1396,6 +1396,7 @@ fn layout_dom_once( opacity_product: f32, list_style: crate::ListStyle, line_height: crate::LineHeight, + text_wrap_style: crate::TextWrapStyle, text_transform: crate::TextTransform, italic: bool, box_sizing: crate::BoxSizing, @@ -1433,6 +1434,7 @@ fn layout_dom_once( // CSS initial value of list-style-type. list_style: crate::ListStyle::Disc, line_height: crate::LineHeight::Normal, + text_wrap_style: crate::TextWrapStyle::Auto, text_transform: crate::TextTransform::None, italic: false, box_sizing: crate::BoxSizing::ContentBox, @@ -1764,6 +1766,7 @@ fn layout_dom_once( 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.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.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) } 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_variation_settings = style.font_variation_settings.clone(); let host_line_height = style.line_height; + let host_text_wrap_style = style.text_wrap_style; let host_transform = style.text_transform; let host_italic = style.font_style_italic; let host_text_align = style.text_align; @@ -2079,6 +2083,9 @@ fn layout_dom_once( } else if pseudo.line_height.is_none() { 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() { pseudo.color = host_color; } diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 499c139..9ed798c 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -278,6 +278,13 @@ fn metadata_variation(metadata: usize) -> Option { /// paint it (filled in after layout). pub struct InlineItem { 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, /// Minimum block-size contributed by explicit `
` breaks. cosmic-text /// 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. @@ -1020,6 +1027,8 @@ impl TextEngine { let idx = self.items.len(); self.items.push(InlineItem { buffer, + balance_wrap: base.text_wrap_style == Some(crate::TextWrapStyle::Balance), + align, forced_min_height, origin: (0.0, 0.0), clip: None, @@ -1093,12 +1102,32 @@ impl TextEngine { /// After layout, pin each context to its final content-box origin and /// clip, reshaping once at the resolved width so paint draws the same /// line breaks the box was sized for. - pub fn finalize(&mut self, idx: usize, content_origin: (f32, f32), content_width: f32, clip: Option) { - let TextEngine { font_system, items, .. } = self; + pub fn finalize( + &mut self, + idx: usize, + content_origin: (f32, f32), + content_width: f32, + clip: Option, + ) { + let TextEngine { + font_system, items, .. + } = self; 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.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; } } @@ -1483,6 +1512,50 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) { (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 { + let natural = buffer.layout_runs().collect::>(); + 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::() / 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 /// plain inline formatting (no atomic inline boxes, no block children, no /// inline element carrying its own box)? Such a container collapses cleanly @@ -2742,6 +2815,84 @@ gamma"#, ); } + fn shaped_line_texts(buffer: &Buffer) -> Vec { + 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("

Welcome to Mozilla

"); + 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#" +
+

balanced heading words

+

ordinary heading words

+
"#, + ); + 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] fn clip_fill_only_for_transparent_clip_text() { let mut s = LayoutStyle::default(); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index b3f0bd8..50817b8 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -619,6 +619,11 @@ pub struct LayoutStyle { /// value (or the initial `normal`). The inline shaper uses this to retain /// author/source newlines in code blocks and to select wrapping behavior. pub white_space: Option, + /// `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, /// Deferred functional line-height (`calc()`, `min()`, `clamp()`) resolved /// after the element font and live viewport are known. pub line_height_expression: Option, @@ -804,6 +809,16 @@ pub enum WhiteSpace { 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)] pub enum TextTransform { None, diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 7580942..abe5a56 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1213,6 +1213,28 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { _ => 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" => { let v = value.trim().to_ascii_lowercase(); 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" | "line-height" | "white-space" + | "text-wrap" + | "text-wrap-style" | "align-items" | "justify-items" | "place-items" @@ -1577,6 +1601,11 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { value.to_ascii_lowercase().as_str(), "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!( value.to_ascii_lowercase().as_str(), "none" | "left" | "right"