From 642b6b57b5dc9e5ee036ec5b1c0746a08ea6f3bb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:01:16 +0000 Subject: [PATCH] Implement inline box continuation geometry --- crates/obscura-render/src/dom.rs | 69 ++++ crates/obscura-render/src/inline.rs | 477 ++++++++++++++++++++++++---- crates/obscura-render/src/paint.rs | 334 ++++++++++++++++++- 3 files changed, 809 insertions(+), 71 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 3e00c6a..93e5a29 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -12325,6 +12325,75 @@ mod tests { ); } + #[test] + fn inline_horizontal_edges_advance_adjacent_content_but_margins_stay_outside_rect() { + let tree = parse_html( + r#" +

aabb

+

aabb

"#, + ); + let laid = layout_dom(&tree, (400.0, 100.0)); + let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; + let local_x = |id: &str, host: &str| rect(id).x - rect(host).x; + + assert!((rect("token").width - rect("plain").width - 12.0).abs() < 0.01); + assert!((local_x("token", "decorated") - local_x("plain", "plain-host") - 3.0).abs() < 0.01); + assert!((local_x("after", "decorated") - local_x("plain-after", "plain-host") - 20.0).abs() < 0.01); + assert!((rect("after").x - (rect("token").x + rect("token").width) - 5.0).abs() < 0.01); + } + + #[test] + fn inline_edges_participate_in_wrapping_and_slice_only_outer_continuation_sides() { + let tree = parse_html( + r#" +

aaaa aaaa aaaa

"#, + ); + let laid = layout_dom(&tree, (200.0, 100.0)); + let token = tree.get_element_by_id("token").unwrap(); + let fragments = &laid.inline_fragments[&token]; + + assert_eq!(fragments.len(), 3, "{fragments:?}"); + assert!((fragments[0].width - fragments[1].width - 12.0).abs() < 0.01, "{fragments:?}"); + assert!(fragments[2].width > fragments[1].width, "last continuation owns its end side: {fragments:?}"); + assert!(fragments.iter().all(|fragment| fragment.width <= 70.01)); + } + + #[test] + fn empty_decorated_inline_has_a_box_and_advances_following_text() { + let tree = parse_html( + r#" +

xy

+

xy

"#, + ); + let laid = layout_dom(&tree, (300.0, 100.0)); + let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; + + assert!((rect("empty").width - 14.0).abs() < 0.01, "{:?}", rect("empty")); + assert!((rect("empty").height - laid.text_engine.inline_font_box_height(&laid.styles[&tree.get_element_by_id("empty").unwrap()]) - 6.0).abs() < 0.01); + let decorated_after = rect("after").x - rect("decorated").x; + let plain_after = rect("plain-after").x - rect("plain-host").x; + assert!((decorated_after - plain_after - 20.0).abs() < 0.01); + } + #[test] fn inline_provenance_maps_repeated_multibyte_text_across_hard_breaks() { let tree = parse_html( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index a820550..db2e3f6 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -433,6 +433,8 @@ pub struct InlineItem { /// with no nested inline owner, keeping that path allocation-free. owner_text: Option, owner_ranges: Vec, + owner_boxes: Vec, + boundary_events: Vec, /// Nonzero used relative-position offsets, projected onto text ranges. /// Empty for ordinary IFCs and for nested inlines that remain at their /// normal-flow position, so paint pays no provenance cost on that path. @@ -446,6 +448,51 @@ struct OwnerTextRange { end: usize, } +#[derive(Clone, Copy, Debug, Default)] +struct InlineEdge { + margin: f32, + border: f32, + padding: f32, +} + +impl InlineEdge { + fn advance(self) -> f32 { + self.margin + self.border + self.padding + } + + fn border_padding(self) -> f32 { + self.border + self.padding + } +} + +#[derive(Clone, Copy, Debug)] +struct InlineOwnerBox { + owner: NodeId, + start: usize, + end: usize, + start_edge: InlineEdge, + end_edge: InlineEdge, + start_event: usize, + end_event: usize, +} + +#[derive(Clone, Copy, Debug)] +struct InlineBoundaryEvent { + owner: NodeId, + position: usize, + is_start: bool, + edge: InlineEdge, +} + +#[derive(Clone, Copy, Debug)] +struct ActiveInlineOwner { + owner: NodeId, + start: usize, + start_edge: InlineEdge, + end_edge: InlineEdge, + start_event: usize, +} + #[derive(Clone, Copy, Debug)] struct RelativeOwnerTextRange { start: usize, @@ -1001,6 +1048,8 @@ impl TextEngine { spans, collector.clip_fills, collector.owner_ranges, + collector.owner_boxes, + collector.boundary_events, ) } @@ -1055,6 +1104,8 @@ impl TextEngine { spans, collector.clip_fills, collector.owner_ranges, + collector.owner_boxes, + collector.boundary_events, ) } @@ -1113,6 +1164,8 @@ impl TextEngine { spans, collector.clip_fills, collector.owner_ranges, + collector.owner_boxes, + collector.boundary_events, ) } @@ -1126,6 +1179,8 @@ impl TextEngine { mut spans: Vec<(String, SpanAttrs)>, clip_fills: Vec, mut owner_ranges: Vec, + mut owner_boxes: Vec, + mut boundary_events: Vec, ) -> Option { let white_space = base.white_space.unwrap_or_default(); let layout_wrap = if spans.iter().any(|(_, attrs)| attrs.has_layout_emergency_breaks()) { @@ -1154,7 +1209,7 @@ impl TextEngine { } } } - if spans.iter().all(|(text, _)| { + if owner_boxes.is_empty() && spans.iter().all(|(text, _)| { text.is_empty() || (matches!( white_space, @@ -1172,7 +1227,14 @@ impl TextEngine { range.end = range.end.min(text_len); range.start < range.end }); - let owner_text = (!owner_ranges.is_empty()).then(|| { + for owner in &mut owner_boxes { + owner.start = owner.start.min(text_len); + owner.end = owner.end.min(text_len); + } + for event in &mut boundary_events { + event.position = event.position.min(text_len); + } + let owner_text = (!owner_boxes.is_empty()).then(|| { let mut text = String::with_capacity(text_len); for (span, _) in &spans { text.push_str(span); @@ -1198,6 +1260,11 @@ impl TextEngine { } let forced_lines = forced_breaks + usize::from(forced_breaks > 0 && visible_after_last_break); + let forced_lines = if owner_boxes.is_empty() { + forced_lines + } else { + forced_lines.max(1) + }; let forced_min_height = forced_lines as f32 * line_h.max(1.0); // cosmic-text asserts (an uncatchable process abort) if font size OR // line height is 0. `font-size:0` is a common whitespace-collapse trick @@ -1298,7 +1365,8 @@ impl TextEngine { let idx = self.items.len(); let text_indent = base.text_indent.unwrap_or(Dimension::Px(0.0)); - let source_buffer = (!matches!(text_indent, Dimension::Px(value) if value == 0.0)) + let source_buffer = (!matches!(text_indent, Dimension::Px(value) if value == 0.0) + || !boundary_events.is_empty()) .then(|| buffer.clone()); self.items.push(InlineItem { buffer, @@ -1320,6 +1388,8 @@ impl TextEngine { marker: None, owner_text, owner_ranges, + owner_boxes, + boundary_events, relative_owner_ranges: Vec::new(), }); Some(idx) @@ -1347,8 +1417,7 @@ impl TextEngine { let TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; shape_with_text_indent(font_system, item, width, wrap); - let (width, height, clamped) = - buffer_size(&item.buffer, item.first_line_offset, item.line_clamp); + let (width, height, clamped) = buffer_size(item); ( width, if clamped { @@ -1374,13 +1443,22 @@ impl TextEngine { shape_with_text_indent(font_system, item, None, wrap); let mut width = 0.0f32; let mut height = 0.0f32; + let starts = item + .owner_text + .as_deref() + .map(|source| source_line_starts(&item.buffer, source)) + .unwrap_or_default(); for (line_index, run) in item.buffer.layout_runs().enumerate() { let offset = if line_index == 0 { item.first_line_offset } else { 0.0 }; - width = width.max((run.line_w + offset).max(0.0)); + let line_start = starts.get(run.line_i).copied().unwrap_or(0); + let line_end = line_start + run.text.len(); + width = width.max( + (run.line_w + offset + line_edge_advance(item, line_start, line_end)).max(0.0), + ); height = height.max(run.line_top + run.line_height); } (width, height.max(item.forced_min_height)) @@ -1540,7 +1618,7 @@ impl TextEngine { } pub(crate) fn has_inline_owners(&self) -> bool { - self.items.iter().any(|item| !item.owner_ranges.is_empty()) + self.items.iter().any(|item| !item.owner_boxes.is_empty()) } /// Install cumulative used offsets for relative ordinary-inline owners. @@ -1588,21 +1666,49 @@ impl TextEngine { } else { 0.0 }; - for range in &item.owner_ranges { - let start = range.start.max(line_start); - let end = range.end.min(line_end); - if start >= end { + let alignment_shift = line_edge_alignment_shift(item, line_start, line_end); + for owner in &item.owner_boxes { + let empty = owner.start == owner.end; + let intersects = if empty { + (owner.start >= line_start && owner.start < line_end) + || (line_end == source.len() && owner.start == line_end) + } else { + owner.start < line_end && owner.end > line_start + }; + if !intersects { continue; } - let start = Cursor::new(run.line_i, start - line_start); - let end = Cursor::new(run.line_i, end - line_start); - let Some((x, width)) = run.highlight(start, end) else { - continue; + let first = owner.start >= line_start && owner.start < line_end || empty; + let last = owner.end > line_start && owner.end <= line_end || empty; + let raw_left = if first { + run_cursor_x(&run, owner.start.saturating_sub(line_start)) + + line_advance_before_event( + item, + owner.start_event, + line_start, + line_end, + ) + + owner.start_edge.margin + } else { + run_cursor_x(&run, 0) }; - let x = item.origin.0 + first_line_offset + x; + let raw_right = if last { + run_cursor_x(&run, owner.end.saturating_sub(line_start)) + + line_advance_before_event( + item, + owner.end_event, + line_start, + line_end, + ) + + owner.end_edge.border_padding() + } else { + run.line_w + line_edge_advance(item, line_start, line_end) + }; + let x = item.origin.0 + first_line_offset + alignment_shift + raw_left; + let width = (raw_right - raw_left).max(0.0); let baseline_y = item.origin.1 + run.line_y; if let Some(existing) = out.iter_mut().find(|fragment| { - fragment.owner == range.owner + fragment.owner == owner.owner && fragment.item_index == item_index && fragment.line_index == line_index }) { @@ -1612,7 +1718,7 @@ impl TextEngine { existing.width = (right - left).max(0.0); } else { out.push(InlineOwnerLineFragment { - owner: range.owner, + owner: owner.owner, item_index, line_index, x, @@ -1770,8 +1876,10 @@ struct SpanCtx { struct Collector { last_was_space: bool, clip_fills: Vec, - owners: Vec, + owners: Vec, owner_ranges: Vec, + owner_boxes: Vec, + boundary_events: Vec, text_len: usize, } @@ -1782,6 +1890,8 @@ impl Collector { clip_fills: Vec::new(), owners: Vec::new(), owner_ranges: Vec::new(), + owner_boxes: Vec::new(), + boundary_events: Vec::new(), text_len: 0, } } @@ -1789,14 +1899,62 @@ impl Collector { fn record_text(&mut self, byte_len: usize) { let start = self.text_len; self.text_len = self.text_len.saturating_add(byte_len); - for &owner in &self.owners { + for owner in &self.owners { self.owner_ranges.push(OwnerTextRange { - owner, + owner: owner.owner, start, end: self.text_len, }); } } + + fn begin_owner(&mut self, owner: NodeId, style: &LayoutStyle) { + let start_edge = InlineEdge { + margin: style.margin.left, + border: style.border.left, + padding: style.padding.left, + }; + let end_edge = InlineEdge { + margin: style.margin.right, + border: style.border.right, + padding: style.padding.right, + }; + let start_event = self.boundary_events.len(); + self.boundary_events.push(InlineBoundaryEvent { + owner, + position: self.text_len, + is_start: true, + edge: start_edge, + }); + self.owners.push(ActiveInlineOwner { + owner, + start: self.text_len, + start_edge, + end_edge, + start_event, + }); + } + + fn end_owner(&mut self, owner: NodeId) { + let active = self.owners.pop().expect("balanced inline owner stack"); + debug_assert_eq!(active.owner, owner); + let end_event = self.boundary_events.len(); + self.boundary_events.push(InlineBoundaryEvent { + owner, + position: self.text_len, + is_start: false, + edge: active.end_edge, + }); + self.owner_boxes.push(InlineOwnerBox { + owner, + start: active.start, + end: self.text_len, + start_edge: active.start_edge, + end_edge: active.end_edge, + start_event: active.start_event, + end_event, + }); + } } /// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent @@ -1966,7 +2124,7 @@ fn collect_node_spans( style.ignores_used_box_sizes() && !style.display_contents }); if owns_inline_fragment { - c.owners.push(cid); + c.begin_owner(cid, style.expect("inline owner style")); } let own_clip_fill = style.and_then(clip_text_fill).map(|fill| { let index = c.clip_fills.len(); @@ -2051,8 +2209,7 @@ fn collect_node_spans( loaded_families, ); if owns_inline_fragment { - let popped = c.owners.pop(); - debug_assert_eq!(popped, Some(cid)); + c.end_owner(cid); } } } @@ -2109,32 +2266,104 @@ fn push_text( out.push((buf, attrs.clone())); } +fn boundary_event_on_line( + item: &InlineItem, + event: &InlineBoundaryEvent, + line_start: usize, + line_end: usize, +) -> bool { + let source_end = item.owner_text.as_ref().map_or(0, String::len); + let empty = item + .owner_boxes + .iter() + .find(|owner| owner.owner == event.owner) + .is_some_and(|owner| owner.start == owner.end); + if empty { + (event.position >= line_start && event.position < line_end) + || (line_end == source_end && event.position == line_end) + } else if event.is_start { + event.position >= line_start && event.position < line_end + } else { + event.position > line_start && event.position <= line_end + } +} + +fn line_edge_advance(item: &InlineItem, line_start: usize, line_end: usize) -> f32 { + item.boundary_events + .iter() + .filter(|event| boundary_event_on_line(item, event, line_start, line_end)) + .map(|event| event.edge.advance()) + .sum() +} + +fn line_edge_alignment_shift(item: &InlineItem, line_start: usize, line_end: usize) -> f32 { + -line_edge_advance(item, line_start, line_end) + * match item.align { + Some(Align::Center) => 0.5, + Some(Align::End | Align::Right) => 1.0, + _ => 0.0, + } +} + +fn line_advance_before_event( + item: &InlineItem, + event_index: usize, + line_start: usize, + line_end: usize, +) -> f32 { + item.boundary_events[..event_index] + .iter() + .filter(|event| boundary_event_on_line(item, event, line_start, line_end)) + .map(|event| event.edge.advance()) + .sum() +} + +fn line_advance_before_text( + item: &InlineItem, + global_position: usize, + line_start: usize, + line_end: usize, +) -> f32 { + item.boundary_events + .iter() + .filter(|event| { + boundary_event_on_line(item, event, line_start, line_end) + && event.position <= global_position + }) + .map(|event| event.edge.advance()) + .sum() +} + /// Total shaped size of a buffer: widest line, and the bottom of the last line. -fn buffer_size( - buffer: &Buffer, - first_line_offset: f32, - line_clamp: Option, -) -> (f32, f32, bool) { +fn buffer_size(item: &InlineItem) -> (f32, f32, bool) { let mut w = 0.0f32; let mut h = 0.0f32; let mut nonempty_lines = 0usize; let mut clamp_height = None; - for (line_index, run) in buffer.layout_runs().enumerate() { + let line_starts = item + .owner_text + .as_deref() + .map(|source| source_line_starts(&item.buffer, source)) + .unwrap_or_default(); + for (line_index, run) in item.buffer.layout_runs().enumerate() { let offset = if line_index == 0 { - first_line_offset + item.first_line_offset } else { 0.0 }; - w = w.max((run.line_w + offset).max(0.0)); + let line_start = line_starts.get(run.line_i).copied().unwrap_or(0); + let line_end = line_start + run.text.len(); + let edges = line_edge_advance(item, line_start, line_end); + w = w.max((run.line_w + offset + edges).max(0.0)); h = h.max(run.line_top + run.line_height); if !run.glyphs.is_empty() { nonempty_lines += 1; - if line_clamp == Some(nonempty_lines) { + if item.line_clamp == Some(nonempty_lines) { clamp_height = Some(run.line_top + run.line_height); } } } - let clamped = line_clamp.is_some_and(|limit| nonempty_lines > limit); + let clamped = item.line_clamp.is_some_and(|limit| nonempty_lines > limit); ( w.ceil(), if clamped { clamp_height.unwrap_or(h) } else { h }, @@ -2170,6 +2399,20 @@ fn source_line_starts(buffer: &Buffer, source: &str) -> Vec { starts } +fn run_cursor_x(run: &cosmic_text::LayoutRun<'_>, byte: usize) -> f32 { + let cursor = Cursor::new(run.line_i, byte.min(run.text.len())); + if let Some((x, _)) = run.highlight(cursor, cursor) { + return x; + } + if byte == 0 { + run.glyphs.first().map_or(0.0, |glyph| glyph.x) + } else { + run.glyphs + .last() + .map_or(run.line_w, |glyph| glyph.x + glyph.w) + } +} + fn glyph_relative_offset( ranges: &[RelativeOwnerTextRange], line_start: usize, @@ -2197,13 +2440,14 @@ fn used_text_indent(value: Dimension, width: Option) -> f32 { if indent.is_finite() { indent } else { 0.0 } } -/// Shape one IFC with a first-line-only indent. +/// Shape one IFC with first-line indent and ordinary-inline boundary advances. /// -/// cosmic-text accepts one wrap width for every visual line. To retain the -/// browser model, first probe the pristine paragraph at `width - indent`, take -/// its first wrap boundary, split there, and reshape the tail at the full -/// content width. The synthetic split lives only in the working clone, so -/// repeated intrinsic/final Taffy measurements never accumulate hard breaks. +/// The pristine buffer remains one shaping stream across DOM owners. For a +/// definite width we probe cosmic-text's own UAX/CSS wrap boundary, subtract +/// the ordered margin/border/padding events that fall on that candidate line, +/// and monotonically retry if those edges force an earlier break. Only final +/// visual-line boundaries split BufferLines, so ligatures and kerning are not +/// broken merely because an inline element starts or ends. fn shape_with_text_indent( font_system: &mut FontSystem, item: &mut InlineItem, @@ -2228,34 +2472,113 @@ fn shape_with_text_indent( item.buffer = source.clone(); item.buffer.set_wrap(font_system, wrap); - let mut first_break = None; - if let Some(full_width) = width { + if let (Some(full_width), Some(source_text)) = (width, item.owner_text.as_deref()) { + let metrics = item.buffer.metrics(); + let mono = item.buffer.monospace_width(); + let tab_width = item.buffer.tab_width(); + let mut line_index = 0usize; + while line_index < item.buffer.lines.len() { + let starts = source_line_starts(&item.buffer, source_text); + let global_start = starts.get(line_index).copied().unwrap_or(0); + let first_indent = if line_index == 0 { indent } else { 0.0 }; + let base_available = (full_width - first_indent).max(0.0); + // Negative inline margins can admit content that would not fit in + // the text-only probe. Begin at the widest possible candidate and + // retain the same monotonic-decrease convergence used for positive + // padding/border advances. + let line_source_end = global_start + item.buffer.lines[line_index].text().len(); + let negative_edges = item + .boundary_events + .iter() + .filter(|event| { + event.position >= global_start && event.position <= line_source_end + }) + .map(|event| event.edge.advance().min(0.0)) + .sum::(); + let mut available = (base_available - negative_edges).max(0.0); + let mut split = None; + + for _ in 0..=item.boundary_events.len() { + let (candidate, line_width) = { + let line = &mut item.buffer.lines[line_index]; + let layouts = line.layout( + font_system, + metrics.font_size, + Some(available), + wrap, + mono, + tab_width, + ); + let Some(first) = layouts.first() else { + break; + }; + ( + first.glyphs.iter().map(|glyph| glyph.end).max(), + first.w, + ) + }; + let Some(candidate) = candidate else { + break; + }; + let candidate_end = global_start.saturating_add(candidate); + let edges = line_edge_advance(item, global_start, candidate_end); + let required_available = (base_available - edges).max(0.0); + split = Some(candidate); + if line_width + edges <= base_available + 0.01 + || required_available + 0.01 >= available + { + break; + } + available = required_available; + item.buffer.lines[line_index].reset_layout(); + } + + let Some(mut split) = split else { + line_index += 1; + continue; + }; + let text = item.buffer.lines[line_index].text(); + while split < text.len() { + let Some(ch) = text[split..].chars().next() else { + break; + }; + if !ch.is_whitespace() { + break; + } + split += ch.len_utf8(); + } + if split > 0 && split < text.len() { + let tail = item.buffer.lines[line_index].split_off(split); + item.buffer.lines.insert(line_index + 1, tail); + } + line_index += 1; + } + } else if let Some(full_width) = width { item.buffer .set_size(font_system, Some((full_width - indent).max(0.0)), None); item.buffer.shape_until_scroll(font_system, false); - first_break = item + let first_break = item .buffer .layout_runs() .next() .and_then(|run| run.glyphs.iter().map(|glyph| glyph.end).max()); - } - - 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()) - { - let text = first_line.text(); - while split < text.len() { - let Some(ch) = text[split..].chars().next() else { break }; - if !ch.is_whitespace() { - break; + 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()) { + let text = first_line.text(); + while split < text.len() { + let Some(ch) = text[split..].chars().next() else { + break; + }; + if !ch.is_whitespace() { + break; + } + split += ch.len_utf8(); + } + if split > 0 && split < text.len() { + let tail = item.buffer.lines[0].split_off(split); + item.buffer.lines.insert(1, tail); } - split += ch.len_utf8(); - } - if split > 0 && split < text.len() { - let tail = item.buffer.lines[0].split_off(split); - item.buffer.lines.insert(1, tail); } } item.buffer @@ -2451,6 +2774,12 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM if !foldable_inline { return false; } + if style.margin != crate::Edges::default() + || style.padding != crate::Edges::default() + || style.border != crate::Edges::default() + { + *has_text = true; + } for gc in crate::dom::rendered_children(tree, cid) { if !inline_child_ok(tree, gc, styles, has_text) { return false; @@ -2572,7 +2901,9 @@ impl TextEngine { let line_source_starts = item .owner_text .as_deref() - .filter(|_| !item.relative_owner_ranges.is_empty()) + .filter(|_| { + !item.relative_owner_ranges.is_empty() || !item.boundary_events.is_empty() + }) .map(|source| source_line_starts(&item.buffer, source)) .unwrap_or_default(); @@ -2590,6 +2921,9 @@ impl TextEngine { 0.0 }; let line_source_start = line_source_starts.get(run.line_i).copied().unwrap_or(0); + let line_source_end = line_source_start + run.text.len(); + let inline_alignment = + line_edge_alignment_shift(item, line_source_start, line_source_end); let base_y = run.line_y; let mut seg: Option<(f32, f32, f32, [u8; 4], (f32, f32))> = None; for g in run.glyphs { @@ -2603,12 +2937,19 @@ impl TextEngine { }) { continue; } - let relative = glyph_relative_offset( + let mut relative = glyph_relative_offset( &item.relative_owner_ranges, line_source_start, g.start, g.end, ); + relative.0 += inline_alignment + + line_advance_before_text( + item, + line_source_start + g.start, + line_source_start, + line_source_end, + ); let underlined = g.metadata & META_UNDERLINE != 0; if let Some(fill_index) = metadata_fill(g.metadata) { if let Some(bounds) = fill_bounds.get_mut(fill_index) { @@ -2677,6 +3018,9 @@ impl TextEngine { 0.0 }; let line_source_start = line_source_starts.get(run.line_i).copied().unwrap_or(0); + let line_source_end = line_source_start + run.text.len(); + let inline_alignment = + line_edge_alignment_shift(item, line_source_start, line_source_end); for glyph in run.glyphs { if item.marker.is_some_and(|marker| { marker.line_index == line_index @@ -2684,12 +3028,19 @@ impl TextEngine { }) { continue; } - let relative = glyph_relative_offset( + let mut relative = glyph_relative_offset( &item.relative_owner_ranges, line_source_start, glyph.start, glyph.end, ); + relative.0 += inline_alignment + + line_advance_before_text( + item, + line_source_start + glyph.start, + line_source_start, + line_source_end, + ); let physical = glyph.physical( (line_offset + relative.0, relative.1), 1.0, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 448a17c..46d8273 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -1694,14 +1694,45 @@ fn paint_laid_dom_scrolled( viewport, ) }); + let has_background_box_paint = !style.background_clip_text + && (style.background_color.is_some() + || style.background_image.is_some() + || style.mask_image.is_some() + || style.background_gradient.is_some() + || style.background_radial_gradient.is_some() + || style.background_conic_gradient.is_some() + || !style.background_gradient_layers.is_empty()); + let has_inline_box_paint = style.box_shadow.is_some() + || has_background_box_paint + || style.border != crate::Edges::default(); + let inline_pieces = (style.ignores_used_box_sizes() && has_inline_box_paint) + .then(|| laid.inline_fragments.get(&nid)) + .flatten(); + let paints_inline_fragments = inline_pieces.is_some(); + if let Some(pieces) = inline_pieces { + paint_inline_fragment_decorations( + &mut pixmap, + pieces, + (ox, oy), + &rect, + style, + clip, + root_font_size, + viewport, + base_url, + image_cache, + ); + } // Outset box-shadow paints behind this element's own background/border. // Geometry comes from the full (translate-adjusted) border box; the // ancestor overflow clip is reapplied inside so the shadow is clipped by // an ancestor exactly as the box itself is. + if !paints_inline_fragments { if let Some(shadow) = style.box_shadow { paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_model.radii, clip); } + } // Box path (rounded if border-radius), reused for gradient/color fill. let radius = style.border_model.radii.resolve(rect.width, rect.height); @@ -1725,7 +1756,7 @@ fn paint_laid_dom_scrolled( // without this it paints white. Takes precedence over a solid color. // `background-clip: text` clips the background to the glyphs, so it must // not paint as a box here; the text paint path fills the glyphs instead. - if style.mask_image.is_none() && !style.background_clip_text { + if !paints_inline_fragments && style.mask_image.is_none() && !style.background_clip_text { if let Some(bg) = style.background_color { if let Some(path) = bg_path() { let mut paint = Paint::default(); @@ -1792,6 +1823,7 @@ fn paint_laid_dom_scrolled( } } + if !paints_inline_fragments { if let Some(mask_url) = &style.mask_image { let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]); paint_mask( @@ -1847,6 +1879,7 @@ fn paint_laid_dom_scrolled( } } } + } let positioned_pseudo_containing_block = { let mut ancestor = Some(nid); let mut found = None; @@ -1903,12 +1936,14 @@ fn paint_laid_dom_scrolled( ); } - paint_css_border( - &mut pixmap, - &rect, - style, - clip_path_mask.as_ref(), - ); + if !paints_inline_fragments { + paint_css_border( + &mut pixmap, + &rect, + style, + clip_path_mask.as_ref(), + ); + } paint_css_outline( &mut pixmap, &rect, @@ -2224,6 +2259,224 @@ fn paint_laid_dom_scrolled( Some(pixmap) } +/// Paint an ordinary inline's sliced border boxes instead of its multiline +/// bounding union. Background coordinates stay anchored to the union so +/// gradients and images remain one joined `box-decoration-break:slice` +/// decoration, while left/right borders and radii appear only on the first +/// and last continuation respectively. +fn paint_inline_fragment_decorations( + pixmap: &mut Pixmap, + fragments: &[crate::Rect], + offset: (f32, f32), + union: &crate::Rect, + style: &crate::LayoutStyle, + clip: Option, + root_font_size: f32, + viewport: (f32, f32), + base_url: Option<&str>, + image_cache: &mut RenderResourceCache, +) { + let union = crate::Rect { + x: union.x, + y: union.y, + width: union.width, + height: union.height, + }; + let mut fragment_style = style.clone(); + for (index, fragment) in fragments.iter().enumerate() { + let fragment = crate::Rect { + x: fragment.x + offset.0, + y: fragment.y + offset.1, + ..*fragment + }; + let visible = match clip { + Some(clip) => match fragment.intersect(&clip) { + Some(visible) => visible, + None => continue, + }, + None => fragment, + }; + let first = index == 0; + let last = index + 1 == fragments.len(); + fragment_style.border.left = style.border.left; + fragment_style.border.right = style.border.right; + fragment_style.border_model.radii = style.border_model.radii; + if !first { + fragment_style.border.left = 0.0; + fragment_style.border_model.radii.top_left = crate::CornerRadius::default(); + fragment_style.border_model.radii.bottom_left = crate::CornerRadius::default(); + } + if !last { + fragment_style.border.right = 0.0; + fragment_style.border_model.radii.top_right = crate::CornerRadius::default(); + fragment_style.border_model.radii.bottom_right = crate::CornerRadius::default(); + } + let radius = fragment_style + .border_model + .radii + .resolve(fragment.width, fragment.height); + let has_radius = !radius.is_zero(); + let clip_path_mask = fragment_style.clip_path.as_ref().and_then(|polygon| { + polygon_clip_mask( + pixmap.width(), + pixmap.height(), + polygon, + &fragment, + fragment_style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + ) + }); + let path = if has_radius { + rounded_rect_path_radii( + visible.x, + visible.y, + visible.width, + visible.height, + radius, + ) + } else { + Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then( + |rect| { + let mut builder = PathBuilder::new(); + builder.push_rect(rect); + builder.finish() + }, + ) + }; + + if let Some(shadow) = fragment_style.box_shadow { + paint_box_shadow( + pixmap, + &shadow, + &fragment, + fragment_style.border_model.radii, + clip, + ); + } + if fragment_style.mask_image.is_none() && !fragment_style.background_clip_text { + if let (Some(background), Some(path)) = (fragment_style.background_color, path.as_ref()) { + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8( + background[0], + background[1], + background[2], + background[3], + )); + paint.anti_alias = has_radius; + pixmap.fill_path( + path, + &paint, + FillRule::Winding, + Transform::identity(), + clip_path_mask.as_ref(), + ); + } + if !fragment_style.background_gradient_layers.is_empty() { + if let Some(path) = path.as_ref() { + paint_background_gradient_layers( + pixmap, + path, + &union, + radius, + &fragment_style, + root_font_size, + viewport, + clip_path_mask.as_ref(), + ); + } + } else { + if let (Some((center, stops)), Some(path)) = + (&fragment_style.background_radial_gradient, path.as_ref()) + { + paint_radial_gradient( + pixmap, + path, + &union, + *center, + stops, + clip_path_mask.as_ref(), + ); + } + if let Some((angle, center, stops)) = &fragment_style.background_conic_gradient { + paint_conic_gradient_sampled( + pixmap, + &fragment, + &union, + radius, + *angle, + *center, + stops, + clip_path_mask.as_ref(), + ); + } + if let (Some((angle, stops)), Some(path)) = + (&fragment_style.background_gradient, path.as_ref()) + { + paint_linear_gradient( + pixmap, + path, + &union, + *angle, + stops, + clip_path_mask.as_ref(), + ); + } + } + } + + if let Some(mask_url) = &fragment_style.mask_image { + let fill = fragment_style + .background_color + .or(fragment_style.color) + .unwrap_or([0, 0, 0, 255]); + paint_mask( + mask_url, + base_url, + &fragment, + radius, + fill, + fragment_style.background_radial_gradient.as_ref(), + fragment_style.background_gradient.as_ref(), + fragment_style.background_conic_gradient.as_ref(), + fragment_style.mask_size, + fragment_style.mask_repeat, + clip_path_mask.as_ref(), + pixmap, + image_cache, + ); + } else if let Some(background_url) = &fragment_style.background_image { + if let Some(image_rect) = background_image_rect( + background_url, + base_url, + &union, + fragment_style.background_size, + fragment_style.background_size_expression.as_deref(), + fragment_style.background_size_fit, + fragment_style.background_position, + fragment_style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + image_cache, + ) { + paint_image( + background_url, + base_url, + &image_rect, + &visible, + crate::ObjectFit::Fill, + pixmap, + image_cache, + None, + radius, + clip_path_mask.as_ref(), + ); + } + } + paint_css_border(pixmap, &fragment, &fragment_style, clip_path_mask.as_ref()); + } +} + /// Per-capture root-scroll and sticky offsets layered over an immutable /// document-space [`DomLayout`]. Keeping these deltas out of the layout avoids /// accumulating movement when the same prepared document paints more than one @@ -4309,6 +4562,28 @@ fn paint_conic_gradient( center: (f32, f32), stops: &[([u8; 4], Option)], extra_clip: Option<&tiny_skia::Mask>, +) { + paint_conic_gradient_sampled( + pixmap, + rect, + rect, + border_radius, + angle, + center, + stops, + extra_clip, + ); +} + +fn paint_conic_gradient_sampled( + pixmap: &mut Pixmap, + rect: &crate::Rect, + sampling_rect: &crate::Rect, + border_radius: crate::ResolvedBorderRadii, + angle: f32, + center: (f32, f32), + stops: &[([u8; 4], Option)], + extra_clip: Option<&tiny_skia::Mask>, ) { if rect.width <= 0.0 || rect.height <= 0.0 || stops.len() < 2 { return; @@ -4322,7 +4597,7 @@ fn paint_conic_gradient( for y in 0..height { for x in 0..width { let color = conic_color_at( - rect, + sampling_rect, angle, center, &normalized, @@ -8213,6 +8488,49 @@ mod tests { ); } + #[test] + fn wrapped_inline_background_and_slice_borders_paint_per_continuation() { + let tree = parse_html( + r#" +

aaaa aaaa aaaa

"#, + ); + let token = tree.get_element_by_id("token").unwrap(); + let mut resources = RenderResourceCache::default(); + let mut prepared = prepare_dom(&tree, (120.0, 100.0), None, &mut resources).unwrap(); + let fragments = prepared.layout.inline_fragments[&token].clone(); + assert_eq!(fragments.len(), 3, "{fragments:?}"); + let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)).unwrap(); + let rgb = |x: f32, y: f32| { + let pixel = pixmap + .pixel(x.floor().max(0.0) as u32, y.floor().max(0.0) as u32) + .unwrap(); + (pixel.red(), pixel.green(), pixel.blue()) + }; + let first = fragments[0]; + let middle = fragments[1]; + let last = fragments[2]; + assert_eq!(rgb(first.x + 0.5, first.y + 1.0), (0, 0, 255)); + assert_eq!(rgb(middle.x + 0.5, middle.y + 1.0), (255, 0, 0)); + assert_eq!( + rgb(last.x + last.width - 0.5, last.y + 1.0), + (0, 0, 255) + ); + let gap_y = (first.y + first.height + middle.y) * 0.5; + assert_eq!( + rgb(5.0, gap_y), + (255, 255, 255), + "the multiline bounding union must not fill the inter-line gap" + ); + } + #[test] fn overflow_clipping_keeps_physical_axes_independent() { let tree = parse_html(