Balance heading line wraps
This commit is contained in:
1 parent
bae1259afe
commit
0ada0fe62d
4 files changed
+206
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -278,6 +278,13 @@ fn metadata_variation(metadata: usize) -> Option<usize> {
|
||||
/// 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<Align>,
|
||||
/// Minimum block-size contributed by explicit `<br>` 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<Rect>) {
|
||||
let TextEngine { font_system, items, .. } = self;
|
||||
pub fn finalize(
|
||||
&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];
|
||||
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<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
|
||||
/// 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</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]
|
||||
fn clip_fill_only_for_transparent_clip_text() {
|
||||
let mut s = LayoutStyle::default();
|
||||
|
||||
@@ -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<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
|
||||
/// after the element font and live viewport are known.
|
||||
pub line_height_expression: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user