From 71193faddffba39d155b5ea5edf932db0c494af4 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Mon, 3 Aug 2026 11:32:45 +0000
Subject: [PATCH] Match CSS text breaking semantics
---
crates/obscura-render/src/dom.rs | 253 +++++++++++++++
crates/obscura-render/src/inline.rs | 171 +++++++++--
crates/obscura-render/src/lib.rs | 34 +++
crates/obscura-render/src/paint.rs | 36 +++
crates/obscura-render/src/style.rs | 82 +++++
vendor/cosmic-text/src/attrs.rs | 57 ++++
vendor/cosmic-text/src/layout.rs | 5 +
vendor/cosmic-text/src/shape.rs | 457 ++++++++++++++++++++++++++--
8 files changed, 1044 insertions(+), 51 deletions(-)
diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs
index c7f2aee..b34c5d7 100644
--- a/crates/obscura-render/src/dom.rs
+++ b/crates/obscura-render/src/dom.rs
@@ -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#"
+
abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ alpha beta gamma
+ alpha beta gamma
+ abcdefghijklmnop
"#,
+ );
+ 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#"
+
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
+ "#,
+ );
+ 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#"
+
+
+
+ "#,
+ );
+ 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#"
+ abcdefghijklmnop
+ abcdefghijklmnop
+ abcdefghijklmnop
"#,
+ );
+ 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#"
+ (ab)
+ (ab)
"#,
+ );
+ 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#"
+ )a
+ )a
+ a)
+ a)
+ a/b
+ a/b
+ a-b
+ a-b
+ a|b
+ a|b
+ a+b
+ a+b
+ $a
+ $a
+ a%
+ a%
"#,
+ );
+ 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#"
+ 標(準)萬ab國.碼
+ 標(準)萬ab國.碼
"#,
+ );
+ 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"));
+ }
+
}
diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs
index 60b39be..aacc6f5 100644
--- a/crates/obscura-render/src/inline.rs
+++ b/crates/obscura-render/src/inline.rs
@@ -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 {
/// 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,
) -> Option {
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,
+ 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,
clip_fill: Option,
+ 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,
clip_fill: Option,
}
@@ -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,
+ 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!("{source}
"));
+ 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 {
let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64")
.chars()
diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs
index a727135..984183f 100644
--- a/crates/obscura-render/src/lib.rs
+++ b/crates/obscura-render/src/lib.rs
@@ -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,
+ /// `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,
+ /// `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,
/// `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
diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs
index 85b273f..36e89b1 100644
--- a/crates/obscura-render/src/paint.rs
+++ b/crates/obscura-render/src/paint.rs
@@ -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#"
+ copy
"#,
+ );
+ 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(
diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs
index a758870..b5bc6e5 100644
--- a/crates/obscura-render/src/style.rs
+++ b/crates/obscura-render/src/style.rs
@@ -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"));
diff --git a/vendor/cosmic-text/src/attrs.rs b/vendor/cosmic-text/src/attrs.rs
index 7e213b7..3b0ea46 100644
--- a/vendor/cosmic-text/src/attrs.rs
+++ b/vendor/cosmic-text/src/attrs.rs
@@ -11,6 +11,51 @@ use crate::{CacheKeyFlags, Metrics};
pub use fontdb::{Family, Stretch, Style, Weight};
+/// CSS-compatible line-breaking policy attached to a rich-text span.
+///
+/// This is optional on [`Attrs`] so existing cosmic-text users retain the
+/// library's ordinary `Wrap` behavior. Browser integrations can opt in when
+/// line-breaking rules vary inside one shaped paragraph.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct CssLineBreak {
+ /// Whether soft wrapping is enabled for this span.
+ pub wrap: bool,
+ /// CSS `word-break` behavior.
+ pub word_break: CssWordBreak,
+ /// CSS `overflow-wrap` behavior.
+ pub overflow_wrap: CssOverflowWrap,
+}
+
+impl Default for CssLineBreak {
+ fn default() -> Self {
+ Self {
+ wrap: true,
+ word_break: CssWordBreak::Normal,
+ overflow_wrap: CssOverflowWrap::Normal,
+ }
+ }
+}
+
+/// CSS `word-break` values used by the line-breaking adapter.
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
+pub enum CssWordBreak {
+ #[default]
+ Normal,
+ BreakAll,
+ KeepAll,
+ /// Legacy `word-break: break-word` compatibility behavior.
+ BreakWord,
+}
+
+/// CSS `overflow-wrap` values used by the line-breaking adapter.
+#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
+pub enum CssOverflowWrap {
+ #[default]
+ Normal,
+ BreakWord,
+ Anywhere,
+}
+
/// Text color
#[derive(Clone, Copy, Debug, PartialOrd, Ord, Eq, Hash, PartialEq)]
pub struct Color(pub u32);
@@ -349,6 +394,8 @@ pub struct Attrs<'a> {
pub letter_spacing_opt: Option,
pub font_features: FontFeatures,
pub font_variations: FontVariations,
+ /// Optional browser-grade line-breaking policy for this text span.
+ pub css_line_break: Option,
}
impl<'a> Attrs<'a> {
@@ -372,6 +419,7 @@ impl<'a> Attrs<'a> {
letter_spacing_opt: None,
font_features: FontFeatures::new(),
font_variations: FontVariations::new(),
+ css_line_break: None,
}
}
@@ -468,6 +516,12 @@ impl<'a> Attrs<'a> {
self
}
+ /// Attach CSS line-breaking behavior to this span.
+ pub fn css_line_break(mut self, policy: CssLineBreak) -> Self {
+ self.css_line_break = Some(policy);
+ self
+ }
+
/// Check if font matches
pub fn matches(&self, face: &fontdb::FaceInfo) -> bool {
//TODO: smarter way of including emoji
@@ -529,6 +583,7 @@ pub struct AttrsOwned {
pub letter_spacing_opt: Option,
pub font_features: FontFeatures,
pub font_variations: FontVariations,
+ pub css_line_break: Option,
}
impl AttrsOwned {
@@ -549,6 +604,7 @@ impl AttrsOwned {
letter_spacing_opt: attrs.letter_spacing_opt,
font_features: attrs.font_features.clone(),
font_variations: attrs.font_variations.clone(),
+ css_line_break: attrs.css_line_break,
}
}
@@ -569,6 +625,7 @@ impl AttrsOwned {
letter_spacing_opt: self.letter_spacing_opt,
font_features: self.font_features.clone(),
font_variations: self.font_variations.clone(),
+ css_line_break: self.css_line_break,
}
}
}
diff --git a/vendor/cosmic-text/src/layout.rs b/vendor/cosmic-text/src/layout.rs
index a7b1327..808b2ea 100644
--- a/vendor/cosmic-text/src/layout.rs
+++ b/vendor/cosmic-text/src/layout.rs
@@ -120,6 +120,10 @@ pub enum Wrap {
Word,
/// Wraps at the word level, or fallback to glyph level if a word can't fit on a line by itself
WordOrGlyph,
+ /// Browser min-content wrapping. Like [`Self::WordOrGlyph`], but only
+ /// span boundaries contributed by `overflow-wrap:anywhere` (and legacy
+ /// `word-break:break-word`) participate.
+ WordOrGlyphMinContent,
}
impl Display for Wrap {
@@ -128,6 +132,7 @@ impl Display for Wrap {
Self::None => write!(f, "No Wrap"),
Self::Word => write!(f, "Word Wrap"),
Self::WordOrGlyph => write!(f, "Word Wrap or Character"),
+ Self::WordOrGlyphMinContent => write!(f, "Min-content Word Wrap or Character"),
Self::Glyph => write!(f, "Character"),
}
}
diff --git a/vendor/cosmic-text/src/shape.rs b/vendor/cosmic-text/src/shape.rs
index 7abcd81..0799eda 100644
--- a/vendor/cosmic-text/src/shape.rs
+++ b/vendor/cosmic-text/src/shape.rs
@@ -8,13 +8,14 @@ use core::cmp::{max, min};
use core::fmt;
use core::mem;
use core::ops::Range;
+use unicode_linebreak::{break_property, linebreaks, BreakClass};
use unicode_script::{Script, UnicodeScript};
use unicode_segmentation::UnicodeSegmentation;
use crate::fallback::FontFallbackIter;
use crate::{
- math, Align, AttrsList, CacheKeyFlags, Color, Font, FontSystem, LayoutGlyph, LayoutLine,
- Metrics, Wrap,
+ math, Align, AttrsList, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap, CssWordBreak,
+ Font, FontSystem, LayoutGlyph, LayoutLine, Metrics, Wrap,
};
/// The shaping strategy of some text.
@@ -687,6 +688,16 @@ impl ShapeGlyph {
pub struct ShapeWord {
pub blank: bool,
pub glyphs: Vec,
+ /// Additional ordinary soft-wrap positions inside this UAX#14 word.
+ /// Values are glyph indices and therefore never split a shaped cluster.
+ soft_breaks: Vec,
+ /// Emergency positions used by constrained layout.
+ emergency_breaks: Vec,
+ /// Emergency positions that also contribute to min-content.
+ min_content_breaks: Vec,
+ /// Whether this word came from CSS-policy-bearing rich text. When false,
+ /// cosmic-text's historical `WordOrGlyph` behavior remains unchanged.
+ custom_line_breaks: bool,
}
impl ShapeWord {
@@ -697,9 +708,92 @@ impl ShapeWord {
Self {
blank: true,
glyphs: Vec::default(),
+ soft_breaks: Vec::new(),
+ emergency_breaks: Vec::new(),
+ min_content_breaks: Vec::new(),
+ custom_line_breaks: false,
}
}
+ fn glyph_indices_for_offsets(&self, mut offsets: Vec) -> Vec {
+ offsets.sort_unstable();
+ offsets.dedup();
+ (1..self.glyphs.len())
+ .filter(|&index| {
+ let before = &self.glyphs[index - 1];
+ let after = &self.glyphs[index];
+ let offset = if before.end <= after.start {
+ after.start
+ } else if after.end <= before.start {
+ before.start
+ } else {
+ return false;
+ };
+ offsets.binary_search(&offset).is_ok()
+ })
+ .collect()
+ }
+
+ fn set_line_breaks(
+ &mut self,
+ custom: bool,
+ soft_offsets: impl Iterator- ,
+ emergency_offsets: impl Iterator
- ,
+ min_content_offsets: impl Iterator
- ,
+ ) {
+ let soft_breaks = self.glyph_indices_for_offsets(soft_offsets.collect());
+ let emergency_breaks = self.glyph_indices_for_offsets(emergency_offsets.collect());
+ let min_content_breaks =
+ self.glyph_indices_for_offsets(min_content_offsets.collect());
+ self.custom_line_breaks = custom;
+ self.soft_breaks = soft_breaks;
+ self.emergency_breaks = emergency_breaks;
+ self.min_content_breaks = min_content_breaks;
+ }
+
+ fn reverse_glyphs(&mut self) {
+ self.glyphs.reverse();
+ let len = self.glyphs.len();
+ for breaks in [
+ &mut self.soft_breaks,
+ &mut self.emergency_breaks,
+ &mut self.min_content_breaks,
+ ] {
+ for index in breaks.iter_mut() {
+ *index = len.saturating_sub(*index);
+ }
+ breaks.sort_unstable();
+ }
+ }
+
+ fn cluster_boundaries(&self) -> Vec {
+ (1..self.glyphs.len())
+ .filter(|&index| {
+ let before = &self.glyphs[index - 1];
+ let after = &self.glyphs[index];
+ before.end <= after.start || after.end <= before.start
+ })
+ .collect()
+ }
+
+ fn break_indices(&self, wrap: Wrap, use_emergency: bool) -> Vec {
+ if wrap == Wrap::Glyph || (!self.custom_line_breaks && use_emergency) {
+ return self.cluster_boundaries();
+ }
+ let mut breaks = self.soft_breaks.clone();
+ if use_emergency {
+ let emergency = if wrap == Wrap::WordOrGlyphMinContent {
+ &self.min_content_breaks
+ } else {
+ &self.emergency_breaks
+ };
+ breaks.extend(emergency.iter().copied());
+ breaks.sort_unstable();
+ breaks.dedup();
+ }
+ breaks
+ }
+
/// Shape a word into a set of glyphs.
#[allow(clippy::too_many_arguments)]
pub fn new(
@@ -785,6 +879,10 @@ impl ShapeWord {
self.blank = blank;
self.glyphs = glyphs;
+ self.soft_breaks.clear();
+ self.emergency_breaks.clear();
+ self.min_content_breaks.clear();
+ self.custom_line_breaks = false;
}
/// Get the width of the [`ShapeWord`] in pixels, using the [`ShapeGlyph::width`] function.
@@ -797,6 +895,219 @@ impl ShapeWord {
}
}
+#[derive(Clone, Copy)]
+struct GraphemeBreakData {
+ end: usize,
+ /// The unmodified UAX#14 class, used by `keep-all`.
+ class: BreakClass,
+ /// Blink's break-all iterator tailors a small number of characters before
+ /// consulting its pair table. Keep that tailoring out of `keep-all`.
+ break_all_class: BreakClass,
+ vertical_line: bool,
+ policy: Option,
+}
+
+fn grapheme_break_class(grapheme: &str) -> BreakClass {
+ grapheme
+ .chars()
+ .map(|ch| break_property(ch as u32))
+ .find(|class| {
+ !matches!(
+ class,
+ BreakClass::CombiningMark | BreakClass::ZeroWidthJoiner
+ )
+ })
+ .unwrap_or(BreakClass::CombiningMark)
+}
+
+/// Letter/number-like UAX#14 classes governed by `word-break`'s "within
+/// words" rules. Complex-context scripts deliberately stay out of keep-all,
+/// matching Blink's exemption for Southeast Asian dictionary breaking.
+fn keep_all_word_class(class: BreakClass) -> bool {
+ matches!(
+ class,
+ BreakClass::Alphabetic
+ | BreakClass::Ambiguous
+ | BreakClass::HebrewLetter
+ | BreakClass::Numeric
+ | BreakClass::Ideographic
+ | BreakClass::ConditionalJapaneseStarter
+ | BreakClass::HangulLvSyllable
+ | BreakClass::HangulLvtSyllable
+ | BreakClass::HangulLJamo
+ | BreakClass::HangulVJamo
+ | BreakClass::HangulTJamo
+ )
+}
+
+/// CSS `break-all` adds opportunities between typographic letter units. The
+/// surrounding UAX#14 iterator continues to own punctuation, emoji, spaces,
+/// joiners, and mandatory breaks; this is the key distinction from terminal
+/// style "break character" wrapping.
+fn break_all_class(grapheme: &str) -> BreakClass {
+ // Blink tailors PLUS SIGN to the alphabetic class for break-all.
+ if grapheme == "+" {
+ BreakClass::Alphabetic
+ } else {
+ grapheme_break_class(grapheme)
+ }
+}
+
+fn break_all_pair(before: GraphemeBreakData, after: GraphemeBreakData) -> bool {
+ use BreakClass::{
+ After, Alphabetic, Ambiguous, CloseParenthesis, ClosePunctuation, ComplexContext,
+ Exclamation, HebrewLetter, Hyphen, InfixSeparator, Numeric, OpenPunctuation, Postfix,
+ Prefix, Symbol,
+ };
+
+ let before_class = before.break_all_class;
+ let after_class = after.break_all_class;
+ let after_vertical_line = after_class == After && after.vertical_line;
+ let al_like_after = matches!(
+ after_class,
+ Ambiguous
+ | Alphabetic
+ | Hyphen
+ | Numeric
+ | OpenPunctuation
+ | Prefix
+ | ComplexContext
+ | HebrewLetter
+ ) || after_vertical_line;
+ match before_class {
+ Ambiguous | Alphabetic | After | Numeric | ComplexContext | Symbol | HebrewLetter => {
+ al_like_after
+ }
+ ClosePunctuation => {
+ matches!(
+ after_class,
+ Ambiguous | Alphabetic | Hyphen | Numeric | Prefix | HebrewLetter
+ ) || after_vertical_line
+ }
+ CloseParenthesis => {
+ matches!(
+ after_class,
+ Ambiguous
+ | Alphabetic
+ | Hyphen
+ | Numeric
+ | Prefix
+ | ComplexContext
+ | HebrewLetter
+ ) || after_vertical_line
+ }
+ Exclamation | Postfix => matches!(
+ after_class,
+ Ambiguous
+ | Alphabetic
+ | Hyphen
+ | Numeric
+ | Postfix
+ | Prefix
+ | HebrewLetter
+ ) || after_vertical_line,
+ InfixSeparator => {
+ matches!(
+ after_class,
+ Ambiguous | Alphabetic | Hyphen | Numeric | HebrewLetter
+ ) || after_vertical_line
+ }
+ Hyphen => after_class == Numeric,
+ Prefix => after_class == Postfix,
+ _ => false,
+ }
+}
+
+fn blink_normal_break(
+ normal_break: bool,
+ before: GraphemeBreakData,
+ after: GraphemeBreakData,
+) -> bool {
+ if !normal_break {
+ return false;
+ }
+
+ // unicode-linebreak follows the default UAX#14 pair table, while Blink's
+ // ICU iterator keeps ASCII solidus and vertical line inside ordinary word
+ // runs (`a/b`, `a|b`). Blink's break-all tailoring then adds the narrower
+ // opportunities explicitly. Keep this browser-specific adjustment behind
+ // CssLineBreak so cosmic-text's existing non-browser behavior is stable.
+ !(before.vertical_line
+ || (before.class == BreakClass::Symbol && keep_all_word_class(after.class)))
+}
+
+struct CssBreakData {
+ word_breaks: Vec,
+ soft_breaks: Vec,
+ emergency_breaks: Vec,
+ min_content_breaks: Vec,
+}
+
+fn css_break_data(span: &str, span_start: usize, attrs_list: &AttrsList) -> CssBreakData {
+ let normal_breaks: Vec = linebreaks(span).map(|(offset, _)| offset).collect();
+ let graphemes: Vec = span
+ .grapheme_indices(true)
+ .map(|(start, grapheme)| {
+ let class = grapheme_break_class(grapheme);
+ GraphemeBreakData {
+ end: start + grapheme.len(),
+ class,
+ break_all_class: break_all_class(grapheme),
+ vertical_line: grapheme == "|",
+ policy: attrs_list.get_span(span_start + start).css_line_break,
+ }
+ })
+ .collect();
+ let mut data = CssBreakData {
+ word_breaks: Vec::new(),
+ soft_breaks: Vec::new(),
+ emergency_breaks: Vec::new(),
+ min_content_breaks: Vec::new(),
+ };
+
+ for pair in graphemes.windows(2) {
+ let before = pair[0];
+ let after = pair[1];
+ let offset = before.end;
+ let absolute = span_start + offset;
+ let normal_break = normal_breaks.binary_search(&offset).is_ok();
+ let Some(policy) = before.policy else {
+ if normal_break {
+ data.word_breaks.push(offset);
+ }
+ continue;
+ };
+ if !policy.wrap {
+ continue;
+ }
+
+ let keep_all = policy.word_break == CssWordBreak::KeepAll
+ && keep_all_word_class(before.class)
+ && keep_all_word_class(after.class);
+ if blink_normal_break(normal_break, before, after) && !keep_all {
+ data.word_breaks.push(offset);
+ } else if policy.word_break == CssWordBreak::BreakAll && break_all_pair(before, after) {
+ data.soft_breaks.push(absolute);
+ }
+
+ let anywhere = policy.word_break == CssWordBreak::BreakWord
+ || policy.overflow_wrap == CssOverflowWrap::Anywhere;
+ let emergency = anywhere || policy.overflow_wrap == CssOverflowWrap::BreakWord;
+ if emergency {
+ data.emergency_breaks.push(absolute);
+ }
+ if anywhere {
+ data.min_content_breaks.push(absolute);
+ }
+ }
+ // ShapeLine represents one mandatory-line segment. Its end must terminate
+ // the final word even when the preceding CSS span disables soft wrapping.
+ data.word_breaks.push(span.len());
+ data.word_breaks.sort_unstable();
+ data.word_breaks.dedup();
+ data
+}
+
/// A shaped span (for bidirectional processing)
#[derive(Clone, Debug)]
pub struct ShapeSpan {
@@ -871,8 +1182,9 @@ impl ShapeSpan {
cached_words.extend(words.drain(..).rev());
}
+ let breaks = css_break_data(span, span_range.start, attrs_list);
let mut start_word = 0;
- for (end_lb, _) in unicode_linebreak::linebreaks(span) {
+ for end_lb in breaks.word_breaks.iter().copied() {
let mut start_lb = end_lb;
for (i, c) in span[start_word..end_lb].char_indices().rev() {
// TODO: Not all whitespace characters are linebreakable, e.g. 00A0 (No-break
@@ -896,6 +1208,43 @@ impl ShapeSpan {
false,
shaping,
);
+ let absolute = (span_range.start + start_word)..(span_range.start + start_lb);
+ let custom = line[absolute.clone()]
+ .char_indices()
+ .any(|(offset, _)| {
+ attrs_list
+ .get_span(absolute.start + offset)
+ .css_line_break
+ .is_some()
+ });
+ let soft_start = breaks
+ .soft_breaks
+ .partition_point(|offset| *offset <= absolute.start);
+ let soft_end = breaks
+ .soft_breaks
+ .partition_point(|offset| *offset < absolute.end);
+ let emergency_start = breaks
+ .emergency_breaks
+ .partition_point(|offset| *offset <= absolute.start);
+ let emergency_end = breaks
+ .emergency_breaks
+ .partition_point(|offset| *offset < absolute.end);
+ let min_start = breaks
+ .min_content_breaks
+ .partition_point(|offset| *offset <= absolute.start);
+ let min_end = breaks
+ .min_content_breaks
+ .partition_point(|offset| *offset < absolute.end);
+ word.set_line_breaks(
+ custom,
+ breaks.soft_breaks[soft_start..soft_end].iter().copied(),
+ breaks.emergency_breaks[emergency_start..emergency_end]
+ .iter()
+ .copied(),
+ breaks.min_content_breaks[min_start..min_end]
+ .iter()
+ .copied(),
+ );
words.push(word);
}
if start_lb < end_lb {
@@ -912,6 +1261,18 @@ impl ShapeSpan {
true,
shaping,
);
+ let absolute = (span_range.start + start_lb + i)
+ ..(span_range.start + start_lb + i + c.len_utf8());
+ let custom = attrs_list
+ .get_span(absolute.start)
+ .css_line_break
+ .is_some();
+ word.set_line_breaks(
+ custom,
+ core::iter::empty(),
+ core::iter::empty(),
+ core::iter::empty(),
+ );
words.push(word);
}
}
@@ -921,7 +1282,7 @@ impl ShapeSpan {
// Reverse glyphs in RTL lines
if line_rtl {
for word in &mut words {
- word.glyphs.reverse();
+ word.reverse_glyphs();
}
}
@@ -1338,13 +1699,21 @@ impl ShapeLine {
}
word_range_width += word_width;
continue;
- } else if wrap == Wrap::Glyph
- // Make sure that the word is able to fit on it's own line, if not, fall back to Glyph wrapping.
- || (wrap == Wrap::WordOrGlyph && word_width > width_opt.unwrap_or(f32::INFINITY))
- {
+ } else {
+ let emergency = wrap == Wrap::Glyph
+ || (matches!(
+ wrap,
+ Wrap::WordOrGlyph | Wrap::WordOrGlyphMinContent
+ ) && word_width > width_opt.unwrap_or(f32::INFINITY));
+ let break_indices = word.break_indices(wrap, emergency);
+ if !break_indices.is_empty() {
// Commit the current line so that the word starts on the next line.
if word_range_width > 0.
- && wrap == Wrap::WordOrGlyph
+ && word.soft_breaks.is_empty()
+ && matches!(
+ wrap,
+ Wrap::WordOrGlyph | Wrap::WordOrGlyphMinContent
+ )
&& word_width > width_opt.unwrap_or(f32::INFINITY)
{
add_to_visual_line(
@@ -1365,18 +1734,29 @@ impl ShapeLine {
fitting_start = (i, 0);
}
- for (glyph_i, glyph) in word.glyphs.iter().enumerate().rev() {
- let glyph_width = glyph.width(font_size);
- if current_visual_line.w + (word_range_width + glyph_width)
+ let mut boundaries = Vec::with_capacity(break_indices.len() + 2);
+ boundaries.push(0);
+ boundaries.extend(break_indices);
+ boundaries.push(word.glyphs.len());
+ for chunk in boundaries.windows(2).rev() {
+ let start = chunk[0];
+ let end = chunk[1];
+ let chunk_width = word.glyphs[start..end]
+ .iter()
+ .map(|glyph| glyph.width(font_size))
+ .sum::();
+ if current_visual_line.w + (word_range_width + chunk_width)
<= width_opt.unwrap_or(f32::INFINITY)
+ || (current_visual_line.ranges.is_empty()
+ && word_range_width == 0.)
{
- word_range_width += glyph_width;
+ word_range_width += chunk_width;
continue;
} else {
add_to_visual_line(
&mut current_visual_line,
span_index,
- (i, glyph_i + 1),
+ (i, end),
fitting_start,
word_range_width,
number_of_blanks,
@@ -1386,8 +1766,8 @@ impl ShapeLine {
cached_visual_lines.pop().unwrap_or_default();
number_of_blanks = 0;
- word_range_width = glyph_width;
- fitting_start = (i, glyph_i + 1);
+ word_range_width = chunk_width;
+ fitting_start = (i, end);
}
}
} else {
@@ -1435,6 +1815,7 @@ impl ShapeLine {
word_range_width = word_width;
fitting_start = (i + 1, 0);
}
+ }
}
}
add_to_visual_line(
@@ -1464,13 +1845,21 @@ impl ShapeLine {
}
word_range_width += word_width;
continue;
- } else if wrap == Wrap::Glyph
- // Make sure that the word is able to fit on it's own line, if not, fall back to Glyph wrapping.
- || (wrap == Wrap::WordOrGlyph && word_width > width_opt.unwrap_or(f32::INFINITY))
- {
+ } else {
+ let emergency = wrap == Wrap::Glyph
+ || (matches!(
+ wrap,
+ Wrap::WordOrGlyph | Wrap::WordOrGlyphMinContent
+ ) && word_width > width_opt.unwrap_or(f32::INFINITY));
+ let break_indices = word.break_indices(wrap, emergency);
+ if !break_indices.is_empty() {
// Commit the current line so that the word starts on the next line.
if word_range_width > 0.
- && wrap == Wrap::WordOrGlyph
+ && word.soft_breaks.is_empty()
+ && matches!(
+ wrap,
+ Wrap::WordOrGlyph | Wrap::WordOrGlyphMinContent
+ )
&& word_width > width_opt.unwrap_or(f32::INFINITY)
{
add_to_visual_line(
@@ -1491,19 +1880,30 @@ impl ShapeLine {
fitting_start = (i, 0);
}
- for (glyph_i, glyph) in word.glyphs.iter().enumerate() {
- let glyph_width = glyph.width(font_size);
- if current_visual_line.w + (word_range_width + glyph_width)
+ let mut boundaries = Vec::with_capacity(break_indices.len() + 2);
+ boundaries.push(0);
+ boundaries.extend(break_indices);
+ boundaries.push(word.glyphs.len());
+ for chunk in boundaries.windows(2) {
+ let start = chunk[0];
+ let end = chunk[1];
+ let chunk_width = word.glyphs[start..end]
+ .iter()
+ .map(|glyph| glyph.width(font_size))
+ .sum::();
+ if current_visual_line.w + (word_range_width + chunk_width)
<= width_opt.unwrap_or(f32::INFINITY)
+ || (current_visual_line.ranges.is_empty()
+ && word_range_width == 0.)
{
- word_range_width += glyph_width;
+ word_range_width += chunk_width;
continue;
} else {
add_to_visual_line(
&mut current_visual_line,
span_index,
fitting_start,
- (i, glyph_i),
+ (i, start),
word_range_width,
number_of_blanks,
);
@@ -1512,8 +1912,8 @@ impl ShapeLine {
cached_visual_lines.pop().unwrap_or_default();
number_of_blanks = 0;
- word_range_width = glyph_width;
- fitting_start = (i, glyph_i);
+ word_range_width = chunk_width;
+ fitting_start = (i, start);
}
}
} else {
@@ -1558,6 +1958,7 @@ impl ShapeLine {
word_range_width = word_width;
fitting_start = (i, 0);
}
+ }
}
}
add_to_visual_line(