Match CSS text breaking semantics
This commit is contained in:
1 parent
34373c309e
commit
71193faddf
8 files changed
+1044
-51
No files matched your search
@@ -2128,6 +2128,8 @@ fn layout_dom_once(
|
||||
list_style: crate::ListStyle,
|
||||
line_height: crate::LineHeight,
|
||||
white_space: crate::WhiteSpace,
|
||||
overflow_wrap: crate::OverflowWrap,
|
||||
word_break: crate::WordBreak,
|
||||
text_wrap_style: crate::TextWrapStyle,
|
||||
text_transform: crate::TextTransform,
|
||||
italic: bool,
|
||||
@@ -2177,6 +2179,8 @@ fn layout_dom_once(
|
||||
list_style: crate::ListStyle::Disc,
|
||||
line_height: crate::LineHeight::Normal,
|
||||
white_space: crate::WhiteSpace::Normal,
|
||||
overflow_wrap: crate::OverflowWrap::Normal,
|
||||
word_break: crate::WordBreak::Normal,
|
||||
text_wrap_style: crate::TextWrapStyle::Auto,
|
||||
text_transform: crate::TextTransform::None,
|
||||
italic: false,
|
||||
@@ -2587,6 +2591,8 @@ fn layout_dom_once(
|
||||
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.white_space { Some(v) => inh.white_space = v, None => style.white_space = Some(inh.white_space) }
|
||||
match style.overflow_wrap { Some(v) => inh.overflow_wrap = v, None => style.overflow_wrap = Some(inh.overflow_wrap) }
|
||||
match style.word_break { Some(v) => inh.word_break = v, None => style.word_break = Some(inh.word_break) }
|
||||
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) }
|
||||
@@ -2708,6 +2714,8 @@ fn layout_dom_once(
|
||||
let host_variation_settings = style.font_variation_settings.clone();
|
||||
let host_line_height = style.line_height;
|
||||
let host_white_space = style.white_space;
|
||||
let host_overflow_wrap = style.overflow_wrap;
|
||||
let host_word_break = style.word_break;
|
||||
let host_text_wrap_style = style.text_wrap_style;
|
||||
let host_transform = style.text_transform;
|
||||
let host_italic = style.font_style_italic;
|
||||
@@ -2935,6 +2943,12 @@ fn layout_dom_once(
|
||||
if pseudo.white_space.is_none() {
|
||||
pseudo.white_space = host_white_space;
|
||||
}
|
||||
if pseudo.overflow_wrap.is_none() {
|
||||
pseudo.overflow_wrap = host_overflow_wrap;
|
||||
}
|
||||
if pseudo.word_break.is_none() {
|
||||
pseudo.word_break = host_word_break;
|
||||
}
|
||||
if pseudo.text_wrap_style.is_none() {
|
||||
pseudo.text_wrap_style = host_text_wrap_style;
|
||||
}
|
||||
@@ -11760,4 +11774,243 @@ mod tests {
|
||||
assert!(!laid.styles[&grid].grid_auto_rows_inherit);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn text_break_modes_match_chromium_fixed_width_geometry() {
|
||||
// Chromium 145 at DPR 1 with Liberation Sans produces the same
|
||||
// 20/40px line boxes. `break-word` and `anywhere` are emergency
|
||||
// wrapping modes; `break-all` adds typographic-letter opportunities.
|
||||
// keep-all must retain ordinary Latin word/space behavior.
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.fixed { width:80px;font:16px/20px "Liberation Sans" }
|
||||
#normal { overflow-wrap:normal }
|
||||
#break-word { overflow-wrap:break-word }
|
||||
#anywhere { overflow-wrap:anywhere }
|
||||
#break-all { word-break:break-all }
|
||||
#keep-all { word-break:keep-all }
|
||||
#legacy { word-break:break-word }
|
||||
</style>
|
||||
<div id="normal" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="break-word" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="anywhere" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="break-all" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="latin-normal" class="fixed">alpha beta gamma</div>
|
||||
<div id="keep-all" class="fixed">alpha beta gamma</div>
|
||||
<div id="legacy" class="fixed">abcdefghijklmnop</div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 500.0));
|
||||
let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||
|
||||
assert_eq!(
|
||||
rect("normal"),
|
||||
Rect { x: 0.0, y: 0.0, width: 80.0, height: 20.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
rect("break-word"),
|
||||
Rect { x: 0.0, y: 20.0, width: 80.0, height: 40.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
rect("anywhere"),
|
||||
Rect { x: 0.0, y: 60.0, width: 80.0, height: 40.0 }
|
||||
);
|
||||
assert_eq!(
|
||||
rect("break-all"),
|
||||
Rect { x: 0.0, y: 100.0, width: 80.0, height: 40.0 }
|
||||
);
|
||||
assert_eq!(rect("latin-normal").height, 40.0);
|
||||
assert_eq!(rect("keep-all").height, rect("latin-normal").height);
|
||||
assert_eq!(rect("legacy").height, 40.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn text_break_inheritance_alias_and_css_wide_values_match_chromium() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.parent { overflow-wrap:anywhere;word-break:break-all;font:16px/20px "Liberation Sans" }
|
||||
.fixed { width:80px }
|
||||
#initial { overflow-wrap:initial;word-break:initial }
|
||||
#unset { overflow-wrap:unset;word-break:unset }
|
||||
#inherit { overflow-wrap:inherit;word-break:inherit }
|
||||
#revert { overflow-wrap:revert;word-break:revert }
|
||||
#revert-layer { overflow-wrap:revert-layer;word-break:revert-layer }
|
||||
#alias { word-wrap:anywhere;word-break:normal }
|
||||
#inherited::before { content:"";display:none }
|
||||
</style>
|
||||
<section class="parent">
|
||||
<div id="inherited" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="initial" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="unset" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="inherit" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="revert" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="revert-layer" class="fixed">abcdefghijklmnop</div>
|
||||
<div id="alias" class="fixed">abcdefghijklmnop</div>
|
||||
</section>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 500.0));
|
||||
let node = |id| tree.get_element_by_id(id).unwrap();
|
||||
let style = |id| &laid.styles[&node(id)];
|
||||
let height = |id| laid.rects[&node(id)].height;
|
||||
|
||||
assert_eq!(style("inherited").overflow_wrap, Some(crate::OverflowWrap::Anywhere));
|
||||
assert_eq!(style("inherited").word_break, Some(crate::WordBreak::BreakAll));
|
||||
assert_eq!(height("inherited"), 40.0);
|
||||
assert_eq!(
|
||||
style("inherited").before_pseudo.as_ref().unwrap().overflow_wrap,
|
||||
Some(crate::OverflowWrap::Anywhere)
|
||||
);
|
||||
assert_eq!(
|
||||
style("inherited").before_pseudo.as_ref().unwrap().word_break,
|
||||
Some(crate::WordBreak::BreakAll)
|
||||
);
|
||||
|
||||
assert_eq!(style("initial").overflow_wrap, Some(crate::OverflowWrap::Normal));
|
||||
assert_eq!(style("initial").word_break, Some(crate::WordBreak::Normal));
|
||||
assert_eq!(height("initial"), 20.0);
|
||||
for id in ["unset", "inherit", "revert", "revert-layer"] {
|
||||
assert_eq!(style(id).overflow_wrap, Some(crate::OverflowWrap::Anywhere), "{id}");
|
||||
assert_eq!(style(id).word_break, Some(crate::WordBreak::BreakAll), "{id}");
|
||||
assert_eq!(height(id), 40.0, "{id}");
|
||||
}
|
||||
assert_eq!(style("alias").overflow_wrap, Some(crate::OverflowWrap::Anywhere));
|
||||
assert_eq!(style("alias").word_break, Some(crate::WordBreak::Normal));
|
||||
assert_eq!(height("alias"), 40.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn anywhere_but_not_break_word_reduces_min_content() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.grid { display:grid;grid-template-columns:min-content;font:16px/20px "Liberation Sans" }
|
||||
.cell { display:block }
|
||||
#normal { overflow-wrap:normal }
|
||||
#break-word { overflow-wrap:break-word }
|
||||
#anywhere { overflow-wrap:anywhere }
|
||||
#break-all { word-break:break-all }
|
||||
</style>
|
||||
<div class="grid"><div id="normal" class="cell">abcdefghijklmnop</div></div>
|
||||
<div class="grid"><div id="break-word" class="cell">abcdefghijklmnop</div></div>
|
||||
<div class="grid"><div id="anywhere" class="cell">abcdefghijklmnop</div></div>
|
||||
<div class="grid"><div id="break-all" class="cell">abcdefghijklmnop</div></div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 1000.0));
|
||||
let width = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()].width;
|
||||
|
||||
assert_eq!(width("normal"), 125.0);
|
||||
assert_eq!(width("break-word"), 125.0);
|
||||
assert_eq!(width("anywhere"), 14.0);
|
||||
assert_eq!(width("break-all"), 14.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn inline_descendant_break_policies_stay_at_their_style_boundaries() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.fixed { width:80px;font:16px/20px "Liberation Sans" }
|
||||
.normal { word-break:normal;overflow-wrap:normal }
|
||||
.break-all { word-break:break-all }
|
||||
.anywhere { overflow-wrap:anywhere }
|
||||
</style>
|
||||
<div id="child-break-all" class="fixed normal"><span class="break-all">abcdefghijklmnop</span></div>
|
||||
<div id="child-normal" class="fixed break-all"><span class="normal">abcdefghijklmnop</span></div>
|
||||
<div id="child-anywhere" class="fixed normal"><span class="anywhere">abcdefghijklmnop</span></div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 300.0));
|
||||
let height = |id| laid.rects[&tree.get_element_by_id(id).unwrap()].height;
|
||||
|
||||
assert_eq!(height("child-break-all"), 40.0);
|
||||
assert_eq!(height("child-normal"), 20.0);
|
||||
assert_eq!(height("child-anywhere"), 40.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn break_all_keeps_uax14_punctuation_pairs_in_min_content() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.grid { display:grid;grid-template-columns:min-content;font:16px/20px "Liberation Sans" }
|
||||
#break-all { word-break:break-all }
|
||||
#anywhere { overflow-wrap:anywhere }
|
||||
</style>
|
||||
<div class="grid"><span id="break-all">(ab)</span></div>
|
||||
<div class="grid"><span id="anywhere">(ab)</span></div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 300.0));
|
||||
let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()];
|
||||
|
||||
assert!(rect("break-all").width > rect("anywhere").width);
|
||||
assert_eq!(rect("break-all").height, 40.0);
|
||||
assert_eq!(rect("anywhere").height, 80.0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn break_all_uses_blink_punctuation_pair_semantics() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.grid { display:grid;grid-template-columns:min-content;font:20px/24px monospace }
|
||||
.break { word-break:break-all }
|
||||
</style>
|
||||
<div class="grid"><span id="close-normal">)a</span></div>
|
||||
<div class="grid"><span id="close-break" class="break">)a</span></div>
|
||||
<div class="grid"><span id="closing-normal">a)</span></div>
|
||||
<div class="grid"><span id="closing-break" class="break">a)</span></div>
|
||||
<div class="grid"><span id="slash-normal">a/b</span></div>
|
||||
<div class="grid"><span id="slash-break" class="break">a/b</span></div>
|
||||
<div class="grid"><span id="hyphen-normal">a-b</span></div>
|
||||
<div class="grid"><span id="hyphen-break" class="break">a-b</span></div>
|
||||
<div class="grid"><span id="bar-normal">a|b</span></div>
|
||||
<div class="grid"><span id="bar-break" class="break">a|b</span></div>
|
||||
<div class="grid"><span id="plus-normal">a+b</span></div>
|
||||
<div class="grid"><span id="plus-break" class="break">a+b</span></div>
|
||||
<div class="grid"><span id="prefix-normal">$a</span></div>
|
||||
<div class="grid"><span id="prefix-break" class="break">$a</span></div>
|
||||
<div class="grid"><span id="postfix-normal">a%</span></div>
|
||||
<div class="grid"><span id="postfix-break" class="break">a%</span></div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 1000.0));
|
||||
let width = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()].width;
|
||||
|
||||
for stem in ["close", "slash", "hyphen", "bar", "plus"] {
|
||||
assert!(
|
||||
width(&format!("{stem}-break")) < width(&format!("{stem}-normal")),
|
||||
"break-all should expose a narrower opportunity for {stem}"
|
||||
);
|
||||
}
|
||||
for stem in ["closing", "prefix", "postfix"] {
|
||||
assert_eq!(
|
||||
width(&format!("{stem}-break")),
|
||||
width(&format!("{stem}-normal")),
|
||||
"break-all must retain the prohibited boundary for {stem}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
#[test]
|
||||
fn keep_all_suppresses_mixed_cjk_latin_word_boundaries() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
html,body { margin:0 }
|
||||
.grid { display:grid;grid-template-columns:min-content;font:16px/20px "Liberation Sans" }
|
||||
#keep { word-break:keep-all }
|
||||
</style>
|
||||
<div class="grid"><span id="normal">標(準)萬ab國.碼</span></div>
|
||||
<div class="grid"><span id="keep">標(準)萬ab國.碼</span></div>"#,
|
||||
);
|
||||
let laid = layout_dom(&tree, (500.0, 500.0));
|
||||
let width = |id| laid.rects[&tree.get_element_by_id(id).unwrap()].width;
|
||||
|
||||
assert!(width("keep") > width("normal"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use cosmic_text::{
|
||||
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FeatureTag, FontFeatures,
|
||||
FontSystem, FontVariations, Metrics, Shaping, Style, SwashCache, SwashImage, VariationTag,
|
||||
Weight, Wrap,
|
||||
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap,
|
||||
CssWordBreak, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics, Shaping,
|
||||
Style, SwashCache, SwashImage, VariationTag, Weight, Wrap,
|
||||
};
|
||||
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
|
||||
use swash::zeno::{Angle, Format, Transform, Vector};
|
||||
@@ -381,6 +381,12 @@ fn metadata_variation(metadata: usize) -> Option<usize> {
|
||||
/// paint it (filled in after layout).
|
||||
pub struct InlineItem {
|
||||
buffer: Buffer,
|
||||
/// Wrapping used for definite-width layout and final paint.
|
||||
layout_wrap: Wrap,
|
||||
/// Wrapping used only for an intrinsic min-content query. This differs
|
||||
/// from `layout_wrap` for `overflow-wrap: break-word`: emergency breaks
|
||||
/// are available during reflow but do not reduce min-content.
|
||||
min_content_wrap: Wrap,
|
||||
/// Unmodified shaped content retained only when `text-indent` is nonzero.
|
||||
/// Each Taffy measurement can then derive a first-line-only wrap boundary
|
||||
/// without accumulating synthetic hard breaks across repeated probes.
|
||||
@@ -1019,6 +1025,9 @@ impl TextEngine {
|
||||
color: context.color,
|
||||
family: context.family,
|
||||
clip_fill: context.clip_fill,
|
||||
white_space: context.white_space,
|
||||
overflow_wrap: context.overflow_wrap,
|
||||
word_break: context.word_break,
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
push_text(
|
||||
@@ -1043,6 +1052,19 @@ impl TextEngine {
|
||||
clip_fills: Vec<ClipTextFill>,
|
||||
) -> Option<usize> {
|
||||
let white_space = base.white_space.unwrap_or_default();
|
||||
let layout_wrap = if spans.iter().any(|(_, attrs)| attrs.has_layout_emergency_breaks()) {
|
||||
Wrap::WordOrGlyph
|
||||
} else {
|
||||
Wrap::Word
|
||||
};
|
||||
let min_content_wrap = if spans
|
||||
.iter()
|
||||
.any(|(_, attrs)| attrs.has_min_content_emergency_breaks())
|
||||
{
|
||||
Wrap::WordOrGlyphMinContent
|
||||
} else {
|
||||
Wrap::Word
|
||||
};
|
||||
// Collapsible trailing whitespace does not widen the last line.
|
||||
if matches!(
|
||||
white_space,
|
||||
@@ -1095,22 +1117,12 @@ impl TextEngine {
|
||||
let cosmic_size = base_size.max(1.0);
|
||||
let metrics = Metrics::new(cosmic_size, line_h.max(1.0));
|
||||
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
||||
// Break only at word boundaries, never mid-word. This keeps the
|
||||
// min-content width (measured at width 0) equal to the longest word,
|
||||
// so a table/flex column never squeezes narrower than a whole word
|
||||
// and forces an ugly mid-word break; a single word wider than its
|
||||
// column overflows instead, matching normal `overflow-wrap: normal`.
|
||||
buffer.set_wrap(
|
||||
&mut self.font_system,
|
||||
if matches!(
|
||||
white_space,
|
||||
crate::WhiteSpace::NoWrap | crate::WhiteSpace::Pre
|
||||
) {
|
||||
Wrap::None
|
||||
} else {
|
||||
Wrap::Word
|
||||
},
|
||||
);
|
||||
// Install the used-layout mode now; intrinsic measurement temporarily
|
||||
// swaps to `min_content_wrap` below. Keeping those modes separate is
|
||||
// what prevents `overflow-wrap: break-word` from shrinking a
|
||||
// table/flex min-content contribution while still permitting an
|
||||
// emergency break once the actual line width is known.
|
||||
buffer.set_wrap(&mut self.font_system, layout_wrap);
|
||||
// Always Advanced shaping: Basic mis-maps per-span attribute
|
||||
// boundaries in the shaping backend (a multi-color run like body text
|
||||
// with links ends up coloring the wrong glyphs), and shaping is a
|
||||
@@ -1166,6 +1178,8 @@ impl TextEngine {
|
||||
.then(|| buffer.clone());
|
||||
self.items.push(InlineItem {
|
||||
buffer,
|
||||
layout_wrap,
|
||||
min_content_wrap,
|
||||
source_buffer,
|
||||
text_indent,
|
||||
first_line_offset: 0.0,
|
||||
@@ -1189,9 +1203,19 @@ impl TextEngine {
|
||||
.size(taffy::Size { width, height: None });
|
||||
return (size.width, size.height);
|
||||
}
|
||||
let wrap = self.items[idx].layout_wrap;
|
||||
self.measure_text_with_wrap(idx, width, wrap)
|
||||
}
|
||||
|
||||
fn measure_text_with_wrap(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
width: Option<f32>,
|
||||
wrap: Wrap,
|
||||
) -> (f32, f32) {
|
||||
let TextEngine { font_system, items, .. } = self;
|
||||
let item = &mut items[idx];
|
||||
shape_with_text_indent(font_system, item, width);
|
||||
shape_with_text_indent(font_system, item, width, wrap);
|
||||
let (width, height) = buffer_size(&item.buffer, item.first_line_offset);
|
||||
(width, height.max(item.forced_min_height))
|
||||
}
|
||||
@@ -1207,7 +1231,8 @@ impl TextEngine {
|
||||
let Some(item) = items.get_mut(idx) else {
|
||||
return (0.0, 0.0);
|
||||
};
|
||||
shape_with_text_indent(font_system, item, None);
|
||||
let wrap = item.layout_wrap;
|
||||
shape_with_text_indent(font_system, item, None, wrap);
|
||||
let mut width = 0.0f32;
|
||||
let mut height = 0.0f32;
|
||||
for (line_index, run) in item.buffer.layout_runs().enumerate() {
|
||||
@@ -1257,12 +1282,19 @@ impl TextEngine {
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let min_content_query =
|
||||
known.width.is_none() && matches!(available.width, taffy::AvailableSpace::MinContent);
|
||||
let width = known.width.or(match available.width {
|
||||
taffy::AvailableSpace::Definite(width) => Some(width),
|
||||
taffy::AvailableSpace::MinContent => Some(0.0),
|
||||
taffy::AvailableSpace::MaxContent => None,
|
||||
});
|
||||
let (width, height) = self.measure(idx, width);
|
||||
let wrap = if min_content_query {
|
||||
self.items[idx].min_content_wrap
|
||||
} else {
|
||||
self.items[idx].layout_wrap
|
||||
};
|
||||
let (width, height) = self.measure_text_with_wrap(idx, width, wrap);
|
||||
taffy::Size { width, height }
|
||||
}
|
||||
|
||||
@@ -1281,7 +1313,8 @@ impl TextEngine {
|
||||
} = self;
|
||||
let item = &mut items[idx];
|
||||
let content_width = content_width.max(0.0);
|
||||
shape_with_text_indent(font_system, item, Some(content_width));
|
||||
let wrap = item.layout_wrap;
|
||||
shape_with_text_indent(font_system, item, Some(content_width), wrap);
|
||||
let balanced_width = item
|
||||
.balance_wrap
|
||||
.then(|| balance_wrap_width(font_system, &mut item.buffer, content_width))
|
||||
@@ -1315,9 +1348,31 @@ struct SpanAttrs {
|
||||
color: [u8; 4],
|
||||
family: Arc<str>,
|
||||
clip_fill: Option<usize>,
|
||||
white_space: crate::WhiteSpace,
|
||||
overflow_wrap: crate::OverflowWrap,
|
||||
word_break: crate::WordBreak,
|
||||
}
|
||||
|
||||
impl SpanAttrs {
|
||||
fn wrapping_enabled(&self) -> bool {
|
||||
!matches!(
|
||||
self.white_space,
|
||||
crate::WhiteSpace::NoWrap | crate::WhiteSpace::Pre
|
||||
)
|
||||
}
|
||||
|
||||
fn has_layout_emergency_breaks(&self) -> bool {
|
||||
self.wrapping_enabled()
|
||||
&& (self.word_break == crate::WordBreak::BreakWord
|
||||
|| self.overflow_wrap != crate::OverflowWrap::Normal)
|
||||
}
|
||||
|
||||
fn has_min_content_emergency_breaks(&self) -> bool {
|
||||
self.wrapping_enabled()
|
||||
&& (self.word_break == crate::WordBreak::BreakWord
|
||||
|| self.overflow_wrap == crate::OverflowWrap::Anywhere)
|
||||
}
|
||||
|
||||
fn to_attrs(&self, variation_index: usize) -> Attrs<'_> {
|
||||
let mut a = Attrs::new().family(Family::Name(self.family.as_ref()));
|
||||
// Inline descendants keep their own computed font metrics inside the
|
||||
@@ -1371,6 +1426,22 @@ impl SpanAttrs {
|
||||
if let Some(variations) = self.variations.as_ref() {
|
||||
a = a.font_variations((**variations).clone());
|
||||
}
|
||||
let word_break = match self.word_break {
|
||||
crate::WordBreak::Normal => CssWordBreak::Normal,
|
||||
crate::WordBreak::BreakAll => CssWordBreak::BreakAll,
|
||||
crate::WordBreak::KeepAll => CssWordBreak::KeepAll,
|
||||
crate::WordBreak::BreakWord => CssWordBreak::BreakWord,
|
||||
};
|
||||
let overflow_wrap = match self.overflow_wrap {
|
||||
crate::OverflowWrap::Normal => CssOverflowWrap::Normal,
|
||||
crate::OverflowWrap::BreakWord => CssOverflowWrap::BreakWord,
|
||||
crate::OverflowWrap::Anywhere => CssOverflowWrap::Anywhere,
|
||||
};
|
||||
a = a.css_line_break(CssLineBreak {
|
||||
wrap: self.wrapping_enabled(),
|
||||
word_break,
|
||||
overflow_wrap,
|
||||
});
|
||||
a = a.metadata(fill | variation | usize::from(self.underline));
|
||||
a
|
||||
}
|
||||
@@ -1394,6 +1465,8 @@ struct SpanCtx {
|
||||
underline: bool,
|
||||
transform: TextTransform,
|
||||
white_space: crate::WhiteSpace,
|
||||
overflow_wrap: crate::OverflowWrap,
|
||||
word_break: crate::WordBreak,
|
||||
family: Arc<str>,
|
||||
clip_fill: Option<usize>,
|
||||
}
|
||||
@@ -1460,6 +1533,8 @@ fn base_span_ctx(
|
||||
underline: base.underline.unwrap_or(false),
|
||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||
white_space: base.white_space.unwrap_or_default(),
|
||||
overflow_wrap: base.overflow_wrap.unwrap_or_default(),
|
||||
word_break: base.word_break.unwrap_or_default(),
|
||||
family: font.family,
|
||||
clip_fill,
|
||||
}
|
||||
@@ -1518,6 +1593,9 @@ fn collect_node_spans(
|
||||
color: ctx.color,
|
||||
family: Arc::clone(&ctx.family),
|
||||
clip_fill: ctx.clip_fill,
|
||||
white_space: ctx.white_space,
|
||||
overflow_wrap: ctx.overflow_wrap,
|
||||
word_break: ctx.word_break,
|
||||
};
|
||||
push_text(
|
||||
contents,
|
||||
@@ -1552,6 +1630,9 @@ fn collect_node_spans(
|
||||
color: ctx.color,
|
||||
family: Arc::clone(&ctx.family),
|
||||
clip_fill: ctx.clip_fill,
|
||||
white_space: ctx.white_space,
|
||||
overflow_wrap: ctx.overflow_wrap,
|
||||
word_break: ctx.word_break,
|
||||
},
|
||||
));
|
||||
c.last_was_space = true;
|
||||
@@ -1623,6 +1704,10 @@ fn collect_node_spans(
|
||||
white_space: style
|
||||
.and_then(|s| s.white_space)
|
||||
.unwrap_or(ctx.white_space),
|
||||
overflow_wrap: style
|
||||
.and_then(|s| s.overflow_wrap)
|
||||
.unwrap_or(ctx.overflow_wrap),
|
||||
word_break: style.and_then(|s| s.word_break).unwrap_or(ctx.word_break),
|
||||
family: font.family,
|
||||
clip_fill,
|
||||
};
|
||||
@@ -1725,6 +1810,7 @@ fn shape_with_text_indent(
|
||||
font_system: &mut FontSystem,
|
||||
item: &mut InlineItem,
|
||||
width: Option<f32>,
|
||||
wrap: Wrap,
|
||||
) {
|
||||
let indent = used_text_indent(item.text_indent, width);
|
||||
item.first_line_offset = indent
|
||||
@@ -1735,12 +1821,14 @@ fn shape_with_text_indent(
|
||||
};
|
||||
|
||||
let Some(source) = item.source_buffer.as_ref() else {
|
||||
item.buffer.set_wrap(font_system, wrap);
|
||||
item.buffer
|
||||
.set_size(font_system, width.map(|value| value.max(0.0)), None);
|
||||
item.buffer.shape_until_scroll(font_system, false);
|
||||
return;
|
||||
};
|
||||
item.buffer = source.clone();
|
||||
item.buffer.set_wrap(font_system, wrap);
|
||||
|
||||
let mut first_break = None;
|
||||
if let Some(full_width) = width {
|
||||
@@ -1755,6 +1843,7 @@ fn shape_with_text_indent(
|
||||
}
|
||||
|
||||
item.buffer = source.clone();
|
||||
item.buffer.set_wrap(font_system, wrap);
|
||||
if let (Some(mut split), Some(first_line)) =
|
||||
(first_break, item.buffer.lines.first())
|
||||
{
|
||||
@@ -2758,6 +2847,9 @@ mod tests {
|
||||
color: [1, 2, 3, 255],
|
||||
family: Arc::from(FAMILY),
|
||||
clip_fill: Some(37),
|
||||
white_space: crate::WhiteSpace::Normal,
|
||||
overflow_wrap: crate::OverflowWrap::Normal,
|
||||
word_break: crate::WordBreak::Normal,
|
||||
};
|
||||
let shaped = attrs.to_attrs(42);
|
||||
assert_ne!(shaped.metadata & META_UNDERLINE, 0);
|
||||
@@ -2835,6 +2927,9 @@ mod tests {
|
||||
color: [0, 0, 0, 255],
|
||||
family: Arc::from(FAMILY),
|
||||
clip_fill: None,
|
||||
white_space: crate::WhiteSpace::Normal,
|
||||
overflow_wrap: crate::OverflowWrap::Normal,
|
||||
word_break: crate::WordBreak::Normal,
|
||||
};
|
||||
let attrs = span.to_attrs(1);
|
||||
assert_eq!(attrs.letter_spacing_opt.unwrap().0, 0.1);
|
||||
@@ -2850,6 +2945,36 @@ mod tests {
|
||||
assert!(zero.to_attrs(1).font_features.features.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_all_never_inserts_controls_inside_grapheme_clusters() {
|
||||
let source = "각각 か\u{3099}か\u{3099} 👨👩👧👦 👍🏽";
|
||||
let tree = obscura_dom::parse_html(&format!("<p id='copy'>{source}</p>"));
|
||||
let copy = tree.get_element_by_id("copy").unwrap();
|
||||
let base = LayoutStyle {
|
||||
display: Display::Block,
|
||||
font_size: Some(40.0),
|
||||
line_height: Some(crate::LineHeight::Px(48.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut keep = base.clone();
|
||||
keep.word_break = Some(crate::WordBreak::KeepAll);
|
||||
|
||||
let mut normal_engine = TextEngine::new();
|
||||
let normal_item = normal_engine
|
||||
.try_build(&tree, copy, &HashMap::from([(copy, base)]))
|
||||
.unwrap();
|
||||
let normal_size = normal_engine.measure(normal_item, None);
|
||||
|
||||
let mut keep_engine = TextEngine::new();
|
||||
let keep_item = keep_engine
|
||||
.try_build(&tree, copy, &HashMap::from([(copy, keep)]))
|
||||
.unwrap();
|
||||
let keep_size = keep_engine.measure(keep_item, None);
|
||||
|
||||
assert_eq!(keep_engine.item_text(keep_item), source);
|
||||
assert_eq!(keep_size, normal_size);
|
||||
}
|
||||
|
||||
fn variable_font_fixture() -> Vec<u8> {
|
||||
let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64")
|
||||
.chars()
|
||||
|
||||
@@ -945,6 +945,19 @@ 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>,
|
||||
/// `overflow-wrap` (legacy alias: `word-wrap`). Inherited; `None` means
|
||||
/// inherit the nearest ancestor's value (or the initial `normal`).
|
||||
///
|
||||
/// `Anywhere` contributes its emergency grapheme opportunities to
|
||||
/// min-content sizing, while legacy `BreakWord` uses those opportunities
|
||||
/// only for actual line layout.
|
||||
pub overflow_wrap: Option<OverflowWrap>,
|
||||
/// `word-break`. Inherited; `None` means inherit the nearest ancestor's
|
||||
/// value (or the initial `normal`). Kept separate from `overflow-wrap`
|
||||
/// because `break-all` adds typographic-letter opportunities while
|
||||
/// retaining UAX#14 punctuation constraints, whereas `keep-all`
|
||||
/// suppresses eligible letter/number boundaries without altering text.
|
||||
pub word_break: Option<WordBreak>,
|
||||
/// `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
|
||||
@@ -1166,6 +1179,27 @@ pub enum WhiteSpace {
|
||||
BreakSpaces,
|
||||
}
|
||||
|
||||
/// Emergency wrapping behavior for otherwise-unbreakable text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum OverflowWrap {
|
||||
#[default]
|
||||
Normal,
|
||||
BreakWord,
|
||||
Anywhere,
|
||||
}
|
||||
|
||||
/// The supported `word-break` values. `BreakWord` is the legacy compatibility
|
||||
/// value whose effective behavior is `word-break: normal` plus
|
||||
/// `overflow-wrap: anywhere`, matching Blink and Gecko.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WordBreak {
|
||||
#[default]
|
||||
Normal,
|
||||
BreakAll,
|
||||
KeepAll,
|
||||
BreakWord,
|
||||
}
|
||||
|
||||
/// Deterministic CSS animation sample time relative to the animation's
|
||||
/// timeline origin. Screenshot rendering currently uses the initial value
|
||||
/// (0ms); keeping it explicit lets capture plumbing freeze other engines at
|
||||
|
||||
@@ -501,6 +501,25 @@ impl PreparedRender {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
let overflow_wrap = match style.overflow_wrap.unwrap_or_default() {
|
||||
crate::OverflowWrap::Normal => "normal",
|
||||
crate::OverflowWrap::BreakWord => "break-word",
|
||||
crate::OverflowWrap::Anywhere => "anywhere",
|
||||
}
|
||||
.to_string();
|
||||
out.insert("overflow-wrap", overflow_wrap.clone());
|
||||
// CSSOM retains the legacy alias as a separately addressable property.
|
||||
out.insert("word-wrap", overflow_wrap);
|
||||
out.insert(
|
||||
"word-break",
|
||||
match style.word_break.unwrap_or_default() {
|
||||
crate::WordBreak::Normal => "normal",
|
||||
crate::WordBreak::BreakAll => "break-all",
|
||||
crate::WordBreak::KeepAll => "keep-all",
|
||||
crate::WordBreak::BreakWord => "break-word",
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
out.insert(
|
||||
"text-align",
|
||||
match style.text_align {
|
||||
@@ -7886,6 +7905,23 @@ mod tests {
|
||||
assert!(found, "computed color should resolve currentColor in inline svg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_style_exposes_text_break_longhands_and_alias() {
|
||||
let tree = parse_html(
|
||||
r#"<style>#copy{overflow-wrap:anywhere;word-break:keep-all}</style>
|
||||
<p id="copy">copy</p>"#,
|
||||
);
|
||||
let copy = tree.get_element_by_id("copy").unwrap();
|
||||
let mut resources = RenderResourceCache::default();
|
||||
let prepared = prepare_dom(&tree, (320.0, 200.0), None, &mut resources)
|
||||
.expect("prepared render");
|
||||
let computed = prepared.computed_style(copy).expect("computed style");
|
||||
|
||||
assert_eq!(computed.get("overflow-wrap").map(String::as_str), Some("anywhere"));
|
||||
assert_eq!(computed.get("word-wrap").map(String::as_str), Some("anywhere"));
|
||||
assert_eq!(computed.get("word-break").map(String::as_str), Some("keep-all"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_inline_svg_with_framework_colon_attribute() {
|
||||
let tree = obscura_dom::parse_html(
|
||||
|
||||
@@ -1454,6 +1454,28 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
_ => style.white_space,
|
||||
};
|
||||
}
|
||||
"overflow-wrap" | "word-wrap" => {
|
||||
style.overflow_wrap = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" | "initial" => Some(crate::OverflowWrap::Normal),
|
||||
"break-word" => Some(crate::OverflowWrap::BreakWord),
|
||||
"anywhere" => Some(crate::OverflowWrap::Anywhere),
|
||||
// Both properties inherit. With no lower-origin declaration
|
||||
// in the compact cascade, revert also exposes the inherited
|
||||
// value, as Blink and Gecko do for these inherited longhands.
|
||||
"inherit" | "unset" | "revert" | "revert-layer" => None,
|
||||
_ => style.overflow_wrap,
|
||||
};
|
||||
}
|
||||
"word-break" => {
|
||||
style.word_break = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" | "initial" => Some(crate::WordBreak::Normal),
|
||||
"break-all" => Some(crate::WordBreak::BreakAll),
|
||||
"keep-all" => Some(crate::WordBreak::KeepAll),
|
||||
"break-word" => Some(crate::WordBreak::BreakWord),
|
||||
"inherit" | "unset" | "revert" | "revert-layer" => None,
|
||||
_ => style.word_break,
|
||||
};
|
||||
}
|
||||
"text-wrap" => {
|
||||
style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" | "wrap" | "initial" | "revert" | "revert-layer" => {
|
||||
@@ -1717,6 +1739,9 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
|
||||
| "text-decoration-line"
|
||||
| "line-height"
|
||||
| "white-space"
|
||||
| "overflow-wrap"
|
||||
| "word-wrap"
|
||||
| "word-break"
|
||||
| "text-wrap"
|
||||
| "text-wrap-style"
|
||||
| "align-items"
|
||||
@@ -1858,6 +1883,14 @@ 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"
|
||||
),
|
||||
"overflow-wrap" | "word-wrap" => matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"normal" | "break-word" | "anywhere"
|
||||
),
|
||||
"word-break" => matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"normal" | "break-all" | "keep-all" | "break-word"
|
||||
),
|
||||
"text-wrap" => matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"auto" | "wrap" | "balance" | "wrap balance" | "balance wrap"
|
||||
@@ -6460,6 +6493,55 @@ mod tests {
|
||||
assert!(!supports_declaration("text-indent", "10px hanging"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_break_longhands_parse_alias_and_css_wide_values() {
|
||||
let values = compute_style(
|
||||
"div",
|
||||
Some(
|
||||
"overflow-wrap:break-word;\
|
||||
word-wrap:anywhere;\
|
||||
word-break:break-all",
|
||||
),
|
||||
);
|
||||
assert_eq!(values.overflow_wrap, Some(crate::OverflowWrap::Anywhere));
|
||||
assert_eq!(values.word_break, Some(crate::WordBreak::BreakAll));
|
||||
|
||||
let legacy = compute_style("div", Some("word-break:break-word"));
|
||||
assert_eq!(legacy.word_break, Some(crate::WordBreak::BreakWord));
|
||||
|
||||
for keyword in ["inherit", "unset", "revert", "revert-layer"] {
|
||||
let style = compute_style(
|
||||
"div",
|
||||
Some(&format!(
|
||||
"overflow-wrap:anywhere;overflow-wrap:{keyword};\
|
||||
word-break:break-all;word-break:{keyword}"
|
||||
)),
|
||||
);
|
||||
assert_eq!(style.overflow_wrap, None, "{keyword}");
|
||||
assert_eq!(style.word_break, None, "{keyword}");
|
||||
}
|
||||
let initial = compute_style(
|
||||
"div",
|
||||
Some(
|
||||
"overflow-wrap:anywhere;overflow-wrap:initial;\
|
||||
word-break:break-all;word-break:initial",
|
||||
),
|
||||
);
|
||||
assert_eq!(initial.overflow_wrap, Some(crate::OverflowWrap::Normal));
|
||||
assert_eq!(initial.word_break, Some(crate::WordBreak::Normal));
|
||||
|
||||
for property in ["overflow-wrap", "word-wrap"] {
|
||||
assert!(supports_declaration(property, "normal"));
|
||||
assert!(supports_declaration(property, "break-word"));
|
||||
assert!(supports_declaration(property, "anywhere"));
|
||||
assert!(!supports_declaration(property, "break-all"));
|
||||
}
|
||||
for value in ["normal", "break-all", "keep-all", "break-word"] {
|
||||
assert!(supports_declaration("word-break", value), "{value}");
|
||||
}
|
||||
assert!(!supports_declaration("word-break", "anywhere"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_unitless_font_size_is_invalid() {
|
||||
let inherited = compute_style("div", Some("font-size:14px;font-size:.813"));
|
||||
|
||||
Reference in new issue
Block a user