diff --git a/parley/src/builder.rs b/parley/src/builder.rs index 2bde73576..f701d93d0 100644 --- a/parley/src/builder.rs +++ b/parley/src/builder.rs @@ -16,7 +16,7 @@ use parley_engine::break_overrides::LineBreakOverrideFn; use crate::InlineBoxKind; use crate::inline_box::InlineBox; -use crate::resolve::{ResolvedStyle, StyleRun, tree::ItemKind}; +use crate::resolve::{ResolvedStyle, StyleRun}; #[derive(Clone, Copy)] pub(crate) struct BuilderOptions<'a> { @@ -256,15 +256,13 @@ impl<'b, B: Brush> TreeBuilder<'b, B> { pub fn push_inline_box(&mut self, mut inline_box: InlineBox) { if inline_box.kind == InlineBoxKind::InFlow { - self.lcx.tree_style_builder.push_uncommitted_text(false); - self.lcx.tree_style_builder.set_is_span_first(false); - self.lcx - .tree_style_builder - .set_last_item_kind(ItemKind::InlineBox); + self.lcx.tree_style_builder.commit_uncommitted_text(); + self.lcx.tree_style_builder.flush_pending_whitespace(); + self.lcx.tree_style_builder.set_last_item_is_inline_box(); } // TODO: arrange type better here to factor out the index - inline_box.index = self.lcx.tree_style_builder.current_text_len(); + inline_box.index = self.lcx.tree_style_builder.committed_text_len(); self.lcx.inline_boxes.push(inline_box); } diff --git a/parley/src/resolve/tree.rs b/parley/src/resolve/tree.rs index 023ea9e72..e1306ce82 100644 --- a/parley/src/resolve/tree.rs +++ b/parley/src/resolve/tree.rs @@ -4,7 +4,7 @@ //! Hierarchical tree based style application. use alloc::{string::String, vec::Vec}; -use crate::style::WhiteSpaceCollapse; +use crate::style::{TextWrapMode, WhiteSpaceCollapse}; use super::{Brush, ResolvedProperty, ResolvedStyle, StyleRun}; @@ -13,13 +13,24 @@ struct StyleTreeNode { parent: Option, style: ResolvedStyle, style_id: Option, + /// The style's id when it is used for whitespace that wrapping is allowed after, which differs + /// from `style_id` only for spans that disable wrapping. + wrappable_style_id: Option, } -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum ItemKind { - None, - InlineBox, - TextRun, +/// A collapsible whitespace sequence that has not been committed yet. +#[derive(Debug, Clone, Copy)] +struct PendingWhitespace { + /// The span the sequence started in, which the collapsed space is attributed to. + span: usize, + /// Whether any of the collapsed whitespace came from a span that allows wrapping, in which + /// case the collapsed space is a soft wrap opportunity. + wrappable: bool, +} + +/// Whether `c` is a segment break, i.e. a character which is a forced line break when preserved. +fn is_segment_break(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') } /// Builder for constructing a tree of styles @@ -32,14 +43,15 @@ pub(crate) struct TreeStyleBuilder { text: String, uncommitted_text: String, current_span: usize, - is_span_first: bool, - last_item_kind: ItemKind, -} - -impl TreeStyleBuilder { - fn current_style(&self) -> ResolvedStyle { - self.tree[self.current_span].style.clone() - } + /// The span that a not-yet-committed collapsible whitespace sequence belongs to. + /// + /// Collapsible whitespace is only committed once it is known to be followed by content in the + /// same inline formatting context, so that it can collapse across span and inline box + /// boundaries and be removed at the end of the text. + pending_whitespace: Option, + /// Whether the most recently pushed item is an inline box, in which case pending collapsible + /// whitespace is not at the start of the inline formatting context. + last_item_is_inline_box: bool, } impl Default for TreeStyleBuilder { @@ -52,13 +64,18 @@ impl Default for TreeStyleBuilder { text: String::new(), uncommitted_text: String::new(), current_span: usize::MAX, - is_span_first: false, - last_item_kind: ItemKind::None, + pending_whitespace: None, + last_item_is_inline_box: false, } } } impl TreeStyleBuilder { + /// The style of the span that text is currently being pushed into. + fn current_style(&self) -> ResolvedStyle { + self.tree[self.current_span].style.clone() + } + /// Prepares the builder for accepting a tree of styles and text. /// /// The provided `root_style` is the default style applied to all text unless overridden. @@ -69,131 +86,201 @@ impl TreeStyleBuilder { self.white_space_collapse = WhiteSpaceCollapse::Preserve; self.text.clear(); self.uncommitted_text.clear(); + self.pending_whitespace = None; + self.last_item_is_inline_box = false; self.tree.push(StyleTreeNode { parent: None, style: root_style, style_id: None, + wrappable_style_id: None, }); self.current_span = 0; - self.is_span_first = true; } + /// Sets the white space collapsing mode applied to subsequently pushed text. pub(crate) fn set_white_space_mode(&mut self, white_space_collapse: WhiteSpaceCollapse) { + // Text pushed so far is processed with the mode that was in effect when it was pushed. + self.commit_uncommitted_text(); self.white_space_collapse = white_space_collapse; } - pub(crate) fn set_is_span_first(&mut self, is_span_first: bool) { - self.is_span_first = is_span_first; + /// Records that an inline box has been pushed, so that following collapsible whitespace is not + /// treated as whitespace at the start of the inline formatting context. + pub(crate) fn set_last_item_is_inline_box(&mut self) { + self.last_item_is_inline_box = true; } - pub(crate) fn set_last_item_kind(&mut self, item_kind: ItemKind) { - self.last_item_kind = item_kind; - } - - pub(crate) fn push_uncommitted_text(&mut self, is_span_last: bool) { + /// Applies white space processing to the buffered text and commits the result, leaving any + /// trailing collapsible whitespace pending. + pub(crate) fn commit_uncommitted_text(&mut self) { let uncommitted_text = core::mem::take(&mut self.uncommitted_text); - let span_text = match self.white_space_collapse { - WhiteSpaceCollapse::Preserve => uncommitted_text, - WhiteSpaceCollapse::Collapse => { - let mut span_text = uncommitted_text.as_str(); + if uncommitted_text.is_empty() { + return; + } - if self.is_span_first - || (self.last_item_kind == ItemKind::TextRun - && self.text.ends_with(|c: char| c.is_ascii_whitespace())) - { - span_text = span_text.trim_ascii_start(); + let span = self.current_span; + match self.white_space_collapse { + WhiteSpaceCollapse::Preserve => { + if uncommitted_text.starts_with(is_segment_break) { + // Pending whitespace is always from a `WhiteSpaceCollapse::Collapse` span, + // and following CSS Text 4 ยง 4.3.1 Rule 1 must be removed if it immediately precedes + // a preserved segment break. + self.pending_whitespace = None; } - if is_span_last { - span_text = span_text.trim_ascii_end(); + self.flush_pending_whitespace(); + self.commit_text(span, &uncommitted_text); + } + WhiteSpaceCollapse::Collapse => { + let mut rest = uncommitted_text.as_str(); + while !rest.is_empty() { + let whitespace_len = rest + .find(|c: char| !c.is_ascii_whitespace()) + .unwrap_or(rest.len()); + if whitespace_len > 0 { + // The collapsed space is attributed to the span the whitespace sequence + // started in, but is a wrap opportunity if any of the spans it collapses + // whitespace from allows wrapping. + let wrappable = self.tree[span].style.text_wrap_mode == TextWrapMode::Wrap; + let pending = self + .pending_whitespace + .get_or_insert(PendingWhitespace { span, wrappable }); + pending.wrappable |= wrappable; + rest = &rest[whitespace_len..]; + continue; + } + + let text_len = rest + .find(|c: char| c.is_ascii_whitespace()) + .unwrap_or(rest.len()); + if rest.starts_with(is_segment_break) { + // Collapsible whitespace immediately preceding a segment break is removed. + // ASCII `CR` and `LF` newlines are collapsed as whitespace, but `LS` and + // `PS` are treated as forced breaks. + self.pending_whitespace = None; + } + self.flush_pending_whitespace(); + self.commit_text(span, &rest[..text_len]); + rest = &rest[text_len..]; } - - // Collapse spaces - let mut last_char_whitespace = false; - span_text - .chars() - .filter_map(|c: char| { - let this_char_whitespace = c.is_ascii_whitespace(); - let prev_char_whitespace = last_char_whitespace; - last_char_whitespace = this_char_whitespace; - - if this_char_whitespace { - if prev_char_whitespace { - None - } else { - Some(' ') - } - } else { - Some(c) - } - }) - .collect() } + } + } + + /// Resolves a pending collapsible whitespace sequence, committing a single space for it if it + /// is followed by content that it can collapse into, and dropping it otherwise. + pub(crate) fn flush_pending_whitespace(&mut self) { + let Some(pending) = self.pending_whitespace.take() else { + return; }; - // Nothing to do if there is no uncommitted text. - if span_text.is_empty() { + // Whitespace at the start of the inline formatting context is removed, as is whitespace + // immediately following a preserved segment break. Whitespace following a preserved space + // or tab is retained, as only collapsible whitespace collapses. + let is_at_start = self.text.is_empty() && !self.last_item_is_inline_box; + if is_at_start || self.text.ends_with(is_segment_break) { return; } - let range = self.text.len()..(self.text.len() + span_text.len()); - let style_index = self.resolve_current_style_id(); - self.style_runs.push(StyleRun { style_index, range }); - self.text.push_str(&span_text); - self.is_span_first = false; - self.last_item_kind = ItemKind::TextRun; + let style_index = if pending.wrappable { + self.resolve_wrappable_style_id(pending.span) + } else { + self.resolve_style_id(pending.span) + }; + self.commit_styled_text(style_index, " "); + } + + /// Appends already white space processed `text` to the buffer, attributed to `span`. + fn commit_text(&mut self, span: usize, text: &str) { + let style_index = self.resolve_style_id(span); + self.commit_styled_text(style_index, text); + } + + /// Appends already white space processed `text` to the buffer with the given style. + fn commit_styled_text(&mut self, style_index: u16, text: &str) { + let start = self.text.len(); + self.text.push_str(text); + match self.style_runs.last_mut() { + Some(run) if run.style_index == style_index && run.range.end == start => { + run.range.end = self.text.len(); + } + _ => self.style_runs.push(StyleRun { + style_index, + range: start..self.text.len(), + }), + } + self.last_item_is_inline_box = false; + } + + /// The index of `span`'s style in the style table, adding it to the table if necessary. + fn resolve_style_id(&mut self, span: usize) -> u16 { + if let Some(style_id) = self.tree[span].style_id { + return style_id; + } + let style_id = self.style_table.len() as u16; + self.style_table.push(self.tree[span].style.clone()); + self.tree[span].style_id = Some(style_id); + style_id } - fn resolve_current_style_id(&mut self) -> u16 { - if let Some(style_id) = self.tree[self.current_span].style_id { + /// The index of `span`'s style with wrapping enabled, adding it to the table if necessary. + fn resolve_wrappable_style_id(&mut self, span: usize) -> u16 { + if self.tree[span].style.text_wrap_mode == TextWrapMode::Wrap { + return self.resolve_style_id(span); + } + if let Some(style_id) = self.tree[span].wrappable_style_id { return style_id; } + let mut style = self.tree[span].style.clone(); + style.text_wrap_mode = TextWrapMode::Wrap; let style_id = self.style_table.len() as u16; - self.style_table.push(self.current_style()); - self.tree[self.current_span].style_id = Some(style_id); + self.style_table.push(style); + self.tree[span].wrappable_style_id = Some(style_id); style_id } - pub(crate) fn current_text_len(&self) -> usize { + /// The length in bytes of the text committed so far, excluding buffered text. + pub(crate) fn committed_text_len(&self) -> usize { self.text.len() } + /// Begins a child span with the given style, which subsequent text is attributed to. pub(crate) fn push_style_span(&mut self, style: ResolvedStyle) { - self.push_uncommitted_text(false); + self.commit_uncommitted_text(); self.tree.push(StyleTreeNode { parent: Some(self.current_span), style, style_id: None, + wrappable_style_id: None, }); self.current_span = self.tree.len() - 1; - self.is_span_first = true; } + /// Begins a child span with the current style modified by the given properties. pub(crate) fn push_style_modification_span( &mut self, properties: impl Iterator>, ) { let mut style = self.current_style(); for prop in properties { - style.apply(prop.clone()); + style.apply(prop); } self.push_style_span(style); } + /// Ends the current span, returning to its parent. pub(crate) fn pop_style_span(&mut self) { - self.push_uncommitted_text(true); + self.commit_uncommitted_text(); self.current_span = self.tree[self.current_span] .parent .expect("Popped root style"); } - /// Pushes a property that covers the specified range of text. + /// Buffers text in the current span, to be white space processed when it is committed. pub(crate) fn push_text(&mut self, text: &str) { - if !text.is_empty() { - self.uncommitted_text.push_str(text); - } + self.uncommitted_text.push_str(text); } /// Computes style table + style runs and returns the final text buffer. @@ -206,7 +293,7 @@ impl TreeStyleBuilder { self.pop_style_span(); } - self.push_uncommitted_text(true); + self.commit_uncommitted_text(); style_table.clear(); style_runs.clear(); @@ -246,6 +333,68 @@ mod tests { assert_eq!(text, "\u{00a0}text\u{00a0}"); } + #[test] + fn collapsible_whitespace_at_span_end_is_committed_with_the_span_style() { + let mut builder = TreeStyleBuilder::::default(); + builder.begin(ResolvedStyle::default()); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_style_modification_span([ResolvedProperty::FontSize(20.)].into_iter()); + builder.push_text("A "); + builder.pop_style_span(); + builder.push_text("B"); + + let mut style_table = Vec::new(); + let mut style_runs = Vec::new(); + let text = builder.finish(&mut style_table, &mut style_runs); + + assert_eq!(text, "A B"); + assert_eq!(style_runs.len(), 2); + assert_eq!(style_runs[0].style_index, 0); + assert_eq!(style_runs[0].range, Range { start: 0, end: 2 }); + assert_eq!(style_runs[1].style_index, 1); + assert_eq!(style_runs[1].range, Range { start: 2, end: 3 }); + } + + #[test] + fn collapsible_whitespace_before_preserved_text_is_committed() { + let mut builder = TreeStyleBuilder::::default(); + builder.begin(ResolvedStyle::default()); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("A "); + builder.push_style_modification_span([].into_iter()); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text(" B "); + builder.pop_style_span(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text(" C"); + + let mut style_table = Vec::new(); + let mut style_runs = Vec::new(); + let text = builder.finish(&mut style_table, &mut style_runs); + + assert_eq!(text, "A B C"); + } + + #[test] + fn collapsible_whitespace_around_a_preserved_segment_break_is_removed() { + let mut builder = TreeStyleBuilder::::default(); + builder.begin(ResolvedStyle::default()); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("A "); + builder.push_style_modification_span([].into_iter()); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("\n"); + builder.pop_style_span(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text(" B"); + + let mut style_table = Vec::new(); + let mut style_runs = Vec::new(); + let text = builder.finish(&mut style_table, &mut style_runs); + + assert_eq!(text, "A\nB"); + } + #[test] fn reuses_style_id_when_returning_to_parent_span() { let mut builder = TreeStyleBuilder::::default(); diff --git a/parley_tests/tests/basic.rs b/parley_tests/tests/basic.rs index 7f5fbfa3d..998e7efcf 100644 --- a/parley_tests/tests/basic.rs +++ b/parley_tests/tests/basic.rs @@ -6,8 +6,9 @@ use crate::util::TestEnv; use crate::{test_name, util::ColorBrush}; use parley::{ - Alignment, AlignmentOptions, BreakReason, ContentWidths, FontFamily, InlineBox, InlineBoxKind, - Layout, LineHeight, PositionedLayoutItem, StyleProperty, TextStyle, WhiteSpaceCollapse, + Alignment, AlignmentOptions, BreakReason, ContentWidths, FontFamily, FontWeight, InlineBox, + InlineBoxKind, Layout, LineHeight, PositionedLayoutItem, StyleProperty, TextStyle, + TextWrapMode, WhiteSpaceCollapse, }; use peniko::color::{AlphaColor, Srgb, palette}; use peniko::kurbo::Size; @@ -440,6 +441,162 @@ fn leading_whitespace() { } } +#[test] +fn collapsible_whitespace_crosses_spans_and_inline_boxes() { + let mut env = TestEnv::new(test_name!(), None); + + let inline_box = || InlineBox { + id: 0, + index: 0, + width: 10., + height: 10., + baseline: None, + kind: InlineBoxKind::InFlow, + }; + + // Trailing whitespace of a span collapses with the content following the span. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_style_modification_span(None); + builder.push_text("Hello "); + builder.pop_style_span(); + builder.push_text("world"); + assert_eq!(builder.build().1, "Hello world"); + + // A whitespace-only span collapses into a single space. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("Hello"); + builder.push_style_modification_span(None); + builder.push_text(" "); + builder.pop_style_span(); + builder.push_text(" world"); + assert_eq!(builder.build().1, "Hello world"); + + // Whitespace collapses across nested span boundaries. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("a "); + builder.push_style_modification_span(None); + builder.push_style_modification_span(None); + builder.push_text(" "); + builder.pop_style_span(); + builder.pop_style_span(); + builder.push_text(" b"); + assert_eq!(builder.build().1, "a b"); + + // Whitespace surrounding an inline box collapses into a single space on each side. The box is + // not whitespace, so the two spaces do not collapse with each other. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("a "); + builder.push_inline_box(inline_box()); + builder.push_text(" b"); + let (layout, text) = builder.build(); + assert_eq!(text, "a b"); + assert_eq!(layout.inline_boxes()[0].index, 2); + + // Whitespace at the start and end of the text is removed, including in enclosing spans. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_style_modification_span(None); + builder.push_text(" "); + builder.pop_style_span(); + builder.push_text("a"); + builder.push_style_modification_span(None); + builder.push_text(" "); + builder.pop_style_span(); + assert_eq!(builder.build().1, "a"); + + // An inline box is not whitespace: whitespace before it is kept. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("a "); + builder.push_inline_box(inline_box()); + let (layout, text) = builder.build(); + assert_eq!(text, "a "); + assert_eq!(layout.inline_boxes()[0].index, 2); + + // Collapsible whitespace on either side of a preserved segment break (e.g. a `
`) is + // removed, while whitespace following a preserved space is kept. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("a "); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text("\n"); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text(" b "); + builder.set_white_space_mode(WhiteSpaceCollapse::Preserve); + builder.push_text(" "); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text(" c"); + assert_eq!(builder.build().1, "a\nb c"); +} + +#[test] +fn collapsed_space_belongs_to_first_span() { + let mut env = TestEnv::new(test_name!(), None); + + // Whitespace spanning a span boundary collapses into a single space, which belongs to the span + // the whitespace sequence started in. + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + builder.push_text("aa "); + builder.push_style_modification_span(&[StyleProperty::FontWeight(FontWeight::BOLD)]); + builder.push_text(" bb"); + builder.pop_style_span(); + let (mut layout, text) = builder.build(); + layout.break_all_lines(None); + assert_eq!(text, "aa bb"); + + let style_indices: Vec = layout + .get(0) + .unwrap() + .runs() + .flat_map(|run| run.clusters().map(|cluster| cluster.style_index())) + .collect(); + let space_style = style_indices[2]; + assert_eq!(space_style, style_indices[0], "space is not bold"); + assert_ne!(space_style, style_indices[3], "\"bb\" is not bold"); +} + +#[test] +fn collapsed_space_wraps_if_any_collapsed_span_wraps() { + let mut env = TestEnv::new(test_name!(), None); + + // Number of lines the given text is broken into at a width that only fits one word, where + // `nowrap` selects which of the two spans disables wrapping. + let mut line_count = |first: &str, second: &str, nowrap: [bool; 2]| { + let mut builder = env.tree_builder(); + builder.set_white_space_mode(WhiteSpaceCollapse::Collapse); + for (text, nowrap) in [first, second].into_iter().zip(nowrap) { + let mode = if nowrap { + TextWrapMode::NoWrap + } else { + TextWrapMode::Wrap + }; + builder.push_style_modification_span(&[StyleProperty::TextWrapMode(mode)]); + builder.push_text(text); + builder.pop_style_span(); + } + let (mut layout, _) = builder.build(); + layout.break_all_lines(Some(30.)); + layout.len() + }; + + // The collapsed space is a wrap opportunity if any of the whitespace it collapses comes from a + // span that allows wrapping, even when it is attributed to a span that does not. + assert_eq!(line_count("aa ", " bb", [true, false]), 2); + assert_eq!(line_count("aa ", " bb", [false, true]), 2); + assert_eq!(line_count("aa ", " bb", [false, false]), 2); + assert_eq!(line_count("aa ", " bb", [true, true]), 1); + + // Whitespace from a single span keeps that span's wrapping behaviour. + assert_eq!(line_count("aa ", "bb", [true, false]), 1); + assert_eq!(line_count("aa", " bb", [false, true]), 1); + assert_eq!(line_count("aa ", "bb", [false, true]), 2); +} + #[test] fn nested_span_inheritance() { let ts = |c: AlphaColor| TextStyle {