From 1a521220d0f9cd9779800830e5453ff9a5177abb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:32:14 +0000 Subject: [PATCH] Expose canonical inline fragments --- crates/obscura-render/src/dom.rs | 364 +++++++++++++++++++++++++++- crates/obscura-render/src/inline.rs | 344 +++++++++++++++++++++++--- 2 files changed, 663 insertions(+), 45 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index d8374b7..3e00c6a 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -4284,6 +4284,71 @@ fn layout_dom_once( } } + // Pure-text IFC descendants do not own Taffy nodes. Once their shared + // buffer has its final line breaks, derive their real continuations from + // shaping provenance and install those fragments as canonical DOM geometry. + // The common paragraph with no nested inline owner skips both this work and + // the second clip-tree walk. + #[cfg(feature = "paint")] + if engine.has_inline_owners() { + let canonical = synthesize_shaped_inline_fragments(tree, &mut rects, &styles, &engine); + if !canonical.is_empty() { + let relative_offsets = canonical + .keys() + .filter_map(|&owner| { + folded_inline_relative_offset(tree, owner, &rects, &styles) + .filter(|offset| *offset != (0.0, 0.0)) + .map(|offset| (owner, offset)) + }) + .collect(); + engine.set_inline_owner_offsets(&relative_offsets); + inline_fragments.extend(canonical); + clip_rects.clear(); + translates.clear(); + transforms.clear(); + if let Some(root_id) = root { + let root_font_size = styles + .get(&root_id) + .and_then(|style| style.font_size) + .unwrap_or(16.0); + resolve_clip_rects( + tree, + root_id, + None, + 0.0, + 0.0, + &rects, + &styles, + &mut clip_rects, + &mut translates, + crate::Affine2::IDENTITY, + &mut transforms, + root_font_size, + viewport, + ); + } + for (nid, &idx) in &ifc_items.whole { + engine.set_clip( + idx, + shaped_item_clip(*nid, &rects, &styles, &clip_rects, &translates, viewport), + ); + } + for (parent, items) in &ifc_items.runs { + let clip = shaped_item_clip( + *parent, + &rects, + &styles, + &clip_rects, + &translates, + viewport, + ); + for &idx in items { + engine.set_clip(idx, clip); + } + } + } + } + let generated_boxes = ifc_items .generated .iter() @@ -4371,6 +4436,180 @@ fn synthesize_ordinary_inline_fragments( fragments } +#[cfg(feature = "paint")] +fn shaped_item_clip( + owner: NodeId, + rects: &HashMap, + styles: &HashMap, + clip_rects: &HashMap>, + translates: &HashMap, + viewport: (f32, f32), +) -> Option { + let inherited = clip_rects.get(&owner).copied().flatten(); + let clip = match (styles.get(&owner), rects.get(&owner)) { + (Some(style), Some(rect)) if style.overflow_hidden => { + let (tx, ty) = translates.get(&owner).copied().unwrap_or((0.0, 0.0)); + let own = OverflowClip::for_box(rect, style, tx, ty); + Some(match inherited { + Some(clip) => clip.intersect(own), + None => own, + }) + } + _ => inherited, + }; + clip.map(|clip| clip.viewport_rect(viewport)) +} + +#[cfg(feature = "paint")] +fn synthesize_shaped_inline_fragments( + tree: &DomTree, + rects: &mut HashMap, + styles: &HashMap, + engine: &crate::inline::TextEngine, +) -> HashMap> { + let mut fragments: HashMap> = HashMap::new(); + for shaped in engine.inline_owner_line_fragments() { + let Some(style) = styles.get(&shaped.owner) else { + continue; + }; + let (ascent, descent) = engine.inline_font_box_metrics(style); + let Some((relative_x, relative_y)) = + folded_inline_relative_offset(tree, shaped.owner, rects, styles) + else { + // Keep the pre-existing Taffy surrogate geometry when an inset + // cannot be resolved faithfully. Installing a knowingly wrong + // shaped fragment would make the DOM geometry canonical. + continue; + }; + let rect = Rect { + x: shaped.x + relative_x, + y: shaped.baseline_y - ascent - style.padding.top - style.border.top + relative_y, + width: shaped.width, + height: ascent + + descent + + style.padding.top + + style.padding.bottom + + style.border.top + + style.border.bottom, + }; + fragments + .entry(shaped.owner) + .or_default() + .push(((shaped.item_index, shaped.line_index), rect)); + } + + let mut canonical = HashMap::with_capacity(fragments.len()); + for (owner, mut pieces) in fragments { + pieces.sort_by(|(order_a, rect_a), (order_b, rect_b)| { + order_a + .cmp(order_b) + .then_with(|| rect_a.x.total_cmp(&rect_b.x)) + }); + let fragments = pieces.into_iter().map(|(_, rect)| rect).collect::>(); + let mut union = fragments[0]; + for fragment in &fragments[1..] { + let left = union.x.min(fragment.x); + let top = union.y.min(fragment.y); + let right = (union.x + union.width).max(fragment.x + fragment.width); + let bottom = (union.y + union.height).max(fragment.y + fragment.height); + union = Rect { + x: left, + y: top, + width: (right - left).max(0.0), + height: (bottom - top).max(0.0), + }; + } + rects.insert(owner, union); + canonical.insert(owner, fragments); + } + canonical +} + +#[cfg(feature = "paint")] +fn folded_inline_relative_offset( + tree: &DomTree, + owner: NodeId, + rects: &HashMap, + styles: &HashMap, +) -> Option<(f32, f32)> { + fn containing_block_size( + tree: &DomTree, + owner: NodeId, + rects: &HashMap, + styles: &HashMap, + ) -> Option<(f32, f32)> { + let mut ancestor = tree.get_node(owner).and_then(|node| node.parent); + while let Some(id) = ancestor { + let style = styles.get(&id)?; + if !style.ignores_used_box_sizes() && !style.display_contents { + let rect = rects.get(&id)?; + return Some(( + (rect.width + - style.border.left + - style.border.right + - style.padding.left + - style.padding.right) + .max(0.0), + (rect.height + - style.border.top + - style.border.bottom + - style.padding.top + - style.padding.bottom) + .max(0.0), + )); + } + ancestor = tree.get_node(id).and_then(|node| node.parent); + } + None + } + + let mut offset = (0.0, 0.0); + let mut current = Some(owner); + while let Some(id) = current { + let Some(style) = styles.get(&id) else { + return None; + }; + if !style.ignores_used_box_sizes() { + break; + } + if style.position == Some(taffy::Position::Relative) && !style.position_sticky { + let basis = containing_block_size(tree, id, rects, styles); + let resolve = |value: Option, horizontal: bool| match value { + None | Some(crate::Dimension::Auto) => Ok(None), + Some(crate::Dimension::Px(value)) if value.is_finite() => Ok(Some(value)), + Some(crate::Dimension::Percent(value)) if value.is_finite() => basis + .map(|size| Some(value * if horizontal { size.0 } else { size.1 })) + .ok_or(()), + _ => Err(()), + }; + // Failed calc()/var() resolution is represented by an expression + // with no corresponding used Dimension. Do not reinterpret it as + // `auto` merely because this sidecar runs after style resolution. + for index in 0..4 { + if style.inset_expressions[index].is_some() && style.inset[index].is_none() { + return None; + } + } + let left = resolve(style.inset[3], true).ok()?; + let right = resolve(style.inset[1], true).ok()?; + let top = resolve(style.inset[0], false).ok()?; + let bottom = resolve(style.inset[2], false).ok()?; + if let Some(left) = left { + offset.0 += left; + } else if let Some(right) = right { + offset.0 -= right; + } + if let Some(top) = top { + offset.1 += top; + } else if let Some(bottom) = bottom { + offset.1 -= bottom; + } + } + current = tree.get_node(id).and_then(|node| node.parent); + } + Some(offset) +} + fn container_snapshot( tree: &DomTree, layout: &DomLayout, @@ -11487,7 +11726,7 @@ mod tests { "#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); - let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; + let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; let row = rect("row"); let column = rect("column"); let quote = rect("quote"); @@ -11526,7 +11765,7 @@ mod tests { "# )); let laid = layout_dom(&tree, (1280.0, 720.0)); - let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; + let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; let column = rect("column"); let quote = rect("quote"); let logo = rect("logo"); @@ -12016,9 +12255,12 @@ mod tests { assert_eq!(laid.rects[&control_host].height, 40.0); assert_eq!(laid.rects[&token].height, expected_fragment_height); assert_eq!(laid.inline_fragments[&token], vec![laid.rects[&token]]); - assert_eq!( - laid.rects[&token].y - laid.rects[&host].y, - (40.0 - raw_font_height) / 2.0 - 5.0 - 2.0 + assert!( + ((laid.rects[&token].y - laid.rects[&host].y) + - ((40.0 - raw_font_height) / 2.0 - 5.0 - 2.0)) + .abs() + < 0.5, + "final shaped baseline must retain the expected centered font box" ); assert_eq!( laid.rects[&control].y - laid.rects[&control_host].y, @@ -12028,7 +12270,111 @@ mod tests { } #[test] - fn fallback_word_boxes_shape_and_paint_with_the_loaded_webfont() { + fn nested_inline_uses_final_shaping_for_its_canonical_fragment() { + let tree = parse_html( + r#" +

before plain nested text after

"#, + ); + let laid = layout_dom(&tree, (400.0, 100.0)); + let token = tree.get_element_by_id("token").unwrap(); + let fragments = &laid.inline_fragments[&token]; + + assert_eq!(fragments.len(), 1, "{fragments:?}"); + assert_eq!(laid.rects[&token], fragments[0]); + assert!(fragments[0].width > 100.0, "{fragments:?}"); + assert_eq!(laid.ifc_items.len(), 1, "the span must stay in one shaped IFC"); + } + + #[test] + fn wrapped_decorated_inline_exposes_three_ordered_font_box_fragments() { + let tree = parse_html( + r#" +

alpha beta gamma

"#, + ); + let laid = layout_dom(&tree, (300.0, 120.0)); + let token = tree.get_element_by_id("token").unwrap(); + let fragments = &laid.inline_fragments[&token]; + + assert_eq!(fragments.len(), 3, "one canonical fragment per visual line: {fragments:?}"); + assert!(fragments.windows(2).all(|pair| pair[0].y < pair[1].y)); + assert!(fragments.iter().all(|fragment| fragment.width > 0.0)); + let left = fragments.iter().map(|rect| rect.x).fold(f32::INFINITY, f32::min); + let top = fragments.iter().map(|rect| rect.y).fold(f32::INFINITY, f32::min); + let right = fragments + .iter() + .map(|rect| rect.x + rect.width) + .fold(f32::NEG_INFINITY, f32::max); + let bottom = fragments + .iter() + .map(|rect| rect.y + rect.height) + .fold(f32::NEG_INFINITY, f32::max); + assert_eq!( + laid.rects[&token], + Rect { x: left, y: top, width: right - left, height: bottom - top } + ); + } + + #[test] + fn inline_provenance_maps_repeated_multibyte_text_across_hard_breaks() { + let tree = parse_html( + r#" +

é界
é界

"#, + ); + let laid = layout_dom(&tree, (300.0, 100.0)); + let token = tree.get_element_by_id("token").unwrap(); + let fragments = &laid.inline_fragments[&token]; + + assert_eq!(fragments.len(), 2, "{fragments:?}"); + assert!((fragments[0].width - fragments[1].width).abs() < 0.01, "{fragments:?}"); + assert!((fragments[1].y - fragments[0].y - 24.0).abs() < 0.01, "{fragments:?}"); + } + + #[test] + fn relative_inline_offsets_move_canonical_fragments_in_pixels_and_percentages() { + let tree = parse_html( + r#" +

token

+

token

+

token

"#, + ); + let laid = layout_dom(&tree, (400.0, 120.0)); + let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; + let local = |token: &str, host: &str| { + let token = rect(token); + let host = rect(host); + (token.x - host.x, token.y - host.y) + }; + let base = local("base", "base-host"); + let pixels = local("pixels", "pixel-host"); + let percent = local("percent", "percent-host"); + + assert!((pixels.0 - base.0 - 7.0).abs() < 0.01, "{base:?} {pixels:?}"); + assert!((pixels.1 - base.1 - 5.0).abs() < 0.01, "{base:?} {pixels:?}"); + assert!((percent.0 - base.0 - 20.0).abs() < 0.01, "{base:?} {percent:?}"); + assert!((percent.1 - base.1 - 2.0).abs() < 0.01, "{base:?} {percent:?}"); + } + + #[test] + fn canonical_inline_fragments_shape_with_the_loaded_webfont() { let tree = parse_html( r#"