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