From 3e377cef679ff73442cfc3030ac62bc2168733c2 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:02:59 +0000 Subject: [PATCH] Match inline font fragment geometry --- crates/obscura-js/js/bootstrap.js | 9 + crates/obscura-js/src/ops.rs | 44 +++-- crates/obscura-js/src/runtime.rs | 59 ++++++ crates/obscura-render/src/dom.rs | 269 +++++++++++++++++++++++++++- crates/obscura-render/src/inline.rs | 64 +++++++ crates/obscura-render/src/lib.rs | 44 +++++ crates/obscura-render/src/paint.rs | 86 ++++++++- render-repros/paired-corpus.py | 3 + render-repros/test_paired_corpus.py | 3 + 9 files changed, 561 insertions(+), 20 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 9eb5b45..20774f2 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -3044,6 +3044,14 @@ class Element extends Node { const geometry = this._renderBoxGeometry(); if (geometry === null) return new DOMRectList([]); if (geometry !== undefined) { + if (Array.isArray(geometry.clientRects)) { + return new DOMRectList(geometry.clientRects.map( + rect => this._rectFromRenderGeometry({ + ...rect, + viewportFixed: geometry.viewportFixed, + }) + )); + } return new DOMRectList([this._rectFromRenderGeometry(geometry)]); } return new DOMRectList([this.getBoundingClientRect()]); @@ -5945,6 +5953,7 @@ globalThis.getComputedStyle = (el) => { 'margin-top': '0px', 'margin-right': '0px', 'margin-bottom': '0px', 'margin-left': '0px', 'padding-top': '0px', 'padding-right': '0px', 'padding-bottom': '0px', 'padding-left': '0px', 'font-size': '16px', 'line-height': 'normal', 'font-weight': '400', + 'letter-spacing': 'normal', 'font-family': 'Times', color: 'rgb(0, 0, 0)', 'background-color': 'rgba(0, 0, 0, 0)', 'border-width': '0px', 'border-style': 'none', 'border-color': 'rgb(0, 0, 0)', diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index f18177d..0e56904 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -2427,10 +2427,11 @@ pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32 /// cache. The cache is computed lazily on first read and cleared on navigation /// (see `set_dom`). Coordinates are viewport-relative after the shared root /// scroll offset, except for viewport-fixed subtrees. Returns JSON -/// `{"x","y","width","height","clientWidth","clientHeight"}` in CSS pixels, -/// or an empty string when the node has no box. The client dimensions are the -/// unscaled padding box used by CSSOM View, whereas the rect is the visual -/// viewport-relative border box. Feature-gated. +/// `{"x","y","width","height","clientWidth","clientHeight","clientRects"}` +/// in CSS pixels, or an empty string when the node has no box. The client +/// dimensions are the unscaled padding box used by CSSOM View, `clientRects` +/// retains every inline continuation, and the top-level rect is their visual +/// viewport-relative bounding union. Feature-gated. #[cfg(feature = "render")] #[op2] #[string] @@ -2447,17 +2448,32 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let Some((client_width, client_height)) = prepared.client_size(nid) else { return String::new(); }; + let Some(client_rects) = prepared.viewport_client_rects(nid, scroll) else { + return String::new(); + }; + let client_rects = client_rects + .into_iter() + .map(|rect| { + serde_json::json!({ + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + }) + }) + .collect::>(); let viewport_fixed = prepared.viewport_fixed_nodes().contains(&nid); - return format!( - "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"clientWidth\":{},\"clientHeight\":{},\"viewportFixed\":{}}}", - rect.x, - rect.y, - rect.width, - rect.height, - client_width, - client_height, - viewport_fixed - ); + return serde_json::json!({ + "x": rect.x, + "y": rect.y, + "width": rect.width, + "height": rect.height, + "clientWidth": client_width, + "clientHeight": client_height, + "clientRects": client_rects, + "viewportFixed": viewport_fixed, + }) + .to_string(); } String::new() } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 4ec70b6..7ab96e0 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2275,6 +2275,9 @@ mod tests { rect: [rect.x, rect.y, rect.width, rect.height], after: [afterRect.x, afterRect.y], client: [token.clientWidth, token.clientHeight], + clientRects: Array.from(token.getClientRects(), r => [ + r.x, r.y, r.width, r.height + ]), atomic: [atomic.width, atomic.height], replaced: [replaced.width, replaced.height], item: [item.width, item.height, getComputedStyle(item).display] @@ -2298,6 +2301,19 @@ mod tests { ); assert!(token_height < 40.0, "ignored block size leaked into geometry"); assert_eq!(result["client"], serde_json::json!([0, 0])); + let client_rects = result["clientRects"].as_array().unwrap(); + assert_eq!(client_rects.len(), 1); + let client_rect = client_rects[0].as_array().unwrap(); + for (actual, expected) in client_rect + .iter() + .map(|value| value.as_f64().unwrap()) + .zip([token_x, token_y, token_width, token_height]) + { + assert!( + (actual - expected).abs() < 0.001, + "getClientRects must expose the renderer's inline fragments" + ); + } let after = result["after"].as_array().unwrap(); assert!(after[0].as_f64().unwrap() >= token_x + token_width - 0.01); assert!((after[1].as_f64().unwrap() - token_y).abs() < 0.01); @@ -2377,6 +2393,49 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn computed_typography_uses_resolved_renderer_values() { + let dom = parse_html( + r#" +
child
+
zero
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(400.0, 200.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const sample = id => { + const s = getComputedStyle(document.getElementById(id)); + return [s.lineHeight, s.letterSpacing, s.whiteSpace, s.textAlign]; + }; + return [sample("parent"), sample("child"), sample("zero")]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + ["30px", "-1px", "pre-wrap", "end"], + ["15px", "-1px", "pre-wrap", "end"], + ["normal", "normal", "break-spaces", "start"], + ]) + ); + } + #[cfg(feature = "render")] #[test] fn computed_style_exposes_cascaded_custom_properties_and_invalidates() { diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 90771f0..1dd1631 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -224,6 +224,11 @@ impl OverflowClip { /// Per-element border boxes after layout, in viewport coordinates. pub struct DomLayout { pub rects: HashMap, + /// Per-line border-box fragments for ordinary non-replaced inline + /// elements. `rects` retains their union for `getBoundingClientRect()`; + /// this list is the source for background/border painting and + /// `getClientRects()` as wrapping support is extended. + pub inline_fragments: HashMap>, pub styles: HashMap, /// Computed custom properties in force for each element. Values are /// reference-counted because the overwhelmingly common case is inheritance @@ -262,6 +267,11 @@ pub struct DomLayout { /// have no DOM node of their own. #[cfg(feature = "paint")] pub run_ifc_items: HashMap>, + /// Webfont-shaped items backing the general per-word fallback. Unlike + /// `run_ifc_items`, these remain keyed by their real DOM text node so they + /// can paint in exact tree order at each independently wrapped Taffy box. + #[cfg(feature = "paint")] + pub word_ifc_items: HashMap>, /// Anonymous in-flow boxes generated by `::before`/`::after`. These have /// no DOM node of their own, but unlike the legacy text-only fast path /// they participate in layout with their pseudo style's real box model. @@ -1019,6 +1029,7 @@ fn sync_positioned_pseudo_percentage_padding( struct IfcRegistry { whole: HashMap, runs: HashMap>, + word_items: HashMap>, generated: Vec, /// Specified column widths per table grid node, from `` elements and /// colspan-1 cells: `(px, percent)` per column index. Consumed by the @@ -2035,6 +2046,7 @@ fn layout_dom_once( .find(|id| tree.get_node(*id).map(|n| n.is_element()).unwrap_or(false)); let mut rects = HashMap::new(); + let mut inline_fragments = HashMap::new(); let mut text_runs = HashMap::new(); // Final absolute rects of anonymous inline-run leaves, keyed by the // engine item index (they have no DOM id to key `rects` by). @@ -3866,6 +3878,8 @@ fn layout_dom_once( &generated_nodes, &mut generated_rects, ); + inline_fragments = + synthesize_ordinary_inline_fragments(&mut rects, &styles, &engine); synthesize_row_rects(tree, &mut rects); } } @@ -3959,6 +3973,23 @@ fn layout_dom_once( } } } + // General word-fallback items are shaped with the same loaded font system + // as full IFCs but retain one independently wrapped Taffy rect per token. + // Their source is a real text node, so clipping/translation follows that + // node while each item is pinned to its anonymous leaf. + #[cfg(feature = "paint")] + for (text_node, items) in &ifc_items.word_items { + let clip = clip_rects + .get(text_node) + .copied() + .flatten() + .map(|clip| clip.viewport_rect(viewport)); + for &idx in items { + if let Some(rect) = anon_rects.get(&idx) { + engine.finalize(idx, (rect.x, rect.y), rect.width, clip); + } + } + } let generated_boxes = ifc_items .generated @@ -3976,6 +4007,7 @@ fn layout_dom_once( ( DomLayout { rects, + inline_fragments, styles, custom_properties, clip_rects, @@ -3987,6 +4019,8 @@ fn layout_dom_once( ifc_items: ifc_items.whole, #[cfg(feature = "paint")] run_ifc_items: ifc_items.runs, + #[cfg(feature = "paint")] + word_ifc_items: ifc_items.word_items, generated_boxes, }, signature, @@ -3995,6 +4029,54 @@ fn layout_dom_once( ) } +/// Convert Taffy's ordinary-inline line surrogate into the element's actual +/// visual fragment box. +/// +/// The surrogate remains at CSS line-height so it contributes the correct +/// line advance. The fragment uses the selected face's grid-fitted ascent plus +/// descent, expanded by block-axis padding and border. Those decorations +/// protrude into the leading and never feed back into the line height. +fn synthesize_ordinary_inline_fragments( + rects: &mut HashMap, + styles: &HashMap, + engine: &crate::inline::TextEngine, +) -> HashMap> { + let mut fragments = HashMap::new(); + let mut unions = Vec::new(); + for (&id, style) in styles { + if !style.ignores_used_box_sizes() { + continue; + } + let Some(flow_rect) = rects.get(&id).copied() else { + continue; + }; + let font_height = engine.inline_font_box_height(style).max(0.0); + let line_height = engine.selected_line_height(style).max(0.0); + // Taffy's surrogate may be taller than this element's own strut when + // a nested inline enlarges the line. Center the element's logical + // line-height inside that allocation before removing its leading. + let logical_top = flow_rect.y + (flow_rect.height - line_height) / 2.0; + let fragment = Rect { + x: flow_rect.x, + y: logical_top + (line_height - font_height) / 2.0 + - style.padding.top + - style.border.top, + width: flow_rect.width, + height: font_height + + style.padding.top + + style.padding.bottom + + style.border.top + + style.border.bottom, + }; + fragments.insert(id, vec![fragment]); + unions.push((id, fragment)); + } + for (id, union) in unions { + rects.insert(id, union); + } + fragments +} + fn container_snapshot( tree: &DomTree, layout: &DomLayout, @@ -5736,7 +5818,15 @@ fn build_any( .map(|n| matches!(n.data, obscura_dom::tree::NodeData::Text { .. })) .unwrap_or(false); if is_text { - return build_text_words(tree, id, taffy_tree, styles, words); + return build_text_words( + tree, + id, + taffy_tree, + styles, + words, + engine, + ifc_items, + ); } // `display: contents` removes the element's own box; its children lay out as // if they were direct children of its parent (CSS Display 3). Splice them @@ -5975,6 +6065,8 @@ fn build_text_words( taffy_tree: &mut TaffyTree, styles: &HashMap, words: &mut HashMap, + engine: &mut crate::inline::TextEngine, + ifc_items: &mut IfcRegistry, ) -> Vec { let Some(node) = tree.get_node(id) else { return Vec::new() }; let obscura_dom::tree::NodeData::Text { contents } = &node.data else { return Vec::new() }; @@ -6011,8 +6103,22 @@ fn build_text_words( letter_spacing = p_style.letter_spacing.unwrap_or(0.0); } } - display_text = transform_word_leaf_text(&display_text, transform); + if let Some(style) = node.parent.and_then(|parent| styles.get(&parent)) { + let shaped = build_shaped_word_leaves( + id, + &display_text, + style, + taffy_tree, + words, + engine, + ifc_items, + ); + if !shaped.is_empty() { + return shaped; + } + } + display_text = transform_word_leaf_text(&display_text, transform); build_word_leaves( id, &display_text, @@ -6026,6 +6132,70 @@ fn build_text_words( ) } +/// Webfont-aware counterpart to [`build_word_leaves`]. +/// +/// The general flex-wrap fallback still needs one independently placeable +/// Taffy leaf per word. Shape each visible token through the render pass's +/// retained `TextEngine`, then reuse that same item at paint time. This keeps +/// loaded faces, variable axes, optical sizing, transforms, and glyph +/// rasterization identical to the full inline-formatting-context path. +fn build_shaped_word_leaves( + source_id: NodeId, + text: &str, + style: &crate::LayoutStyle, + taffy_tree: &mut TaffyTree, + words: &mut HashMap, + engine: &mut crate::inline::TextEngine, + ifc_items: &mut IfcRegistry, +) -> Vec { + let mut leaves = Vec::new(); + for token in tokenize_with_spaces(text) { + if token.trim().is_empty() { + continue; + } + // A token normally retains one trailing collapsed space. Shape it as + // preformatted content so the item keeps that advance instead of the + // paragraph path trimming collapsible trailing whitespace. + let mut token_style = style.clone(); + token_style.white_space = Some(crate::WhiteSpace::Pre); + let Some(item) = engine.push_generated_text(&token, &token_style) else { + // Returning no leaves makes the caller use the deterministic + // layout-only/static-font fallback for the whole text node. + return Vec::new(); + }; + let (width, height) = engine.measure_word(item); + let taffy_style = taffy::Style { + size: taffy::Size { + width: taffy::Dimension::length(width.max(0.0)), + height: taffy::Dimension::length(height.max(0.0)), + }, + ..Default::default() + }; + let Ok(leaf) = taffy_tree.new_leaf_with_context(taffy_style, item) else { + return Vec::new(); + }; + words.insert( + leaf, + ( + source_id, + transform_word_leaf_text( + &token, + style + .text_transform + .unwrap_or(crate::TextTransform::None), + ), + ), + ); + ifc_items + .word_items + .entry(source_id) + .or_default() + .push(item); + leaves.push(leaf); + } + leaves +} + fn transform_word_leaf_text(text: &str, transform: crate::TextTransform) -> String { match transform { crate::TextTransform::None => text.to_string(), @@ -10985,6 +11155,101 @@ mod tests { ); } + #[test] + fn ordinary_inline_fragment_uses_font_box_while_line_keeps_line_height() { + let tree = parse_html( + r#" +

token

+

token

"#, + ); + let laid = layout_dom(&tree, (400.0, 200.0)); + let host = tree.get_element_by_id("with-margin").unwrap(); + let control_host = tree.get_element_by_id("without-margin").unwrap(); + let token = tree.get_element_by_id("token").unwrap(); + let control = tree.get_element_by_id("control").unwrap(); + let style = &laid.styles[&token]; + let raw_font_height = laid.text_engine.inline_font_box_height(style); + let expected_fragment_height = raw_font_height + 5.0 + 7.0 + 2.0 + 3.0; + + assert_eq!( + laid.rects[&host].height, 40.0, + "block-axis inline padding/border must not increase line advance" + ); + 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_eq!( + laid.rects[&control].y - laid.rects[&control_host].y, + laid.rects[&token].y - laid.rects[&host].y, + "ordinary inline block-axis margins must not move its fragment" + ); + } + + #[test] + fn fallback_word_boxes_shape_and_paint_with_the_loaded_webfont() { + let tree = parse_html( + r#" +

WWWWiiii

"#, + ); + let token = tree.get_element_by_id("token").unwrap(); + let text = rendered_children(&tree, token)[0]; + let fallback = layout_dom(&tree, (500.0, 150.0)); + let loaded = layout_dom_with_web_fonts( + &tree, + (500.0, 150.0), + &HashMap::new(), + &[crate::inline::WebFont { + data: include_bytes!("../assets/liberation-serif.ttf").to_vec(), + family: Some("Fixture".to_string()), + weight: Some((400, 400)), + italic: Some(false), + }], + ); + + assert!( + loaded.word_ifc_items.get(&text).is_some_and(|items| !items.is_empty()), + "a decorated/relative inline fallback must retain shaped word items" + ); + assert_ne!( + fallback.rects[&token].width, loaded.rects[&token].width, + "the loaded face's advances must drive fallback inline geometry" + ); + assert_ne!( + fallback.rects[&token].height, loaded.rects[&token].height, + "the loaded face's raw ascent/descent must drive the visual fragment" + ); + assert_eq!( + loaded.inline_fragments[&token], + vec![loaded.rects[&token]], + "the loaded-font fragment must be the geometry/paint source" + ); + } + #[test] fn positioned_pseudo_inherits_the_hosts_computed_font_metrics() { let tree = parse_html( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 7362679..60b39be 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -300,6 +300,18 @@ fn normal_line_height(font_size: f32, metrics: FaceMetrics) -> f32 { + (metrics.line_gap * scale).round() } +/// Grid-fitted font ascent plus descent, excluding both the face line gap and +/// authored CSS `line-height`. +/// +/// A non-replaced inline has two different vertical boxes. Its line +/// participation uses [`used_line_height_with_metrics`], while its painted +/// fragment/client rect uses this raw font box plus block-axis padding and +/// border. Chromium and Gecko both fit ascent and descent independently. +fn font_box_height(font_size: f32, metrics: FaceMetrics) -> f32 { + let scale = font_size / metrics.units_per_em.max(1.0); + (metrics.ascent * scale).round() + (metrics.descent * scale).round() +} + fn font_metrics( db: &cosmic_text::fontdb::Database, id: cosmic_text::fontdb::ID, @@ -848,6 +860,32 @@ impl TextEngine { pub fn len(&self) -> usize { self.items.len() } + + /// Raw selected-font box used by ordinary inline fragments. This is + /// deliberately not CSS `line-height`: leading belongs to the containing + /// line and must not enlarge backgrounds, borders, or DOM client rects. + pub(crate) fn inline_font_box_height(&self, style: &LayoutStyle) -> f32 { + let font = resolve_loaded_font( + style.font_family.as_deref(), + crate::style::used_font_weight(style), + style.font_style_italic.unwrap_or(false), + &self.loaded_families, + ); + font_box_height(style.font_size.unwrap_or(16.0), font.metrics) + } + + /// Used line-height for the same selected face. Kept beside + /// [`inline_font_box_height`](Self::inline_font_box_height) so layout can + /// distribute leading around the raw fragment using one font decision. + pub(crate) fn selected_line_height(&self, style: &LayoutStyle) -> f32 { + let font = resolve_loaded_font( + style.font_family.as_deref(), + crate::style::used_font_weight(style), + style.font_style_italic.unwrap_or(false), + &self.loaded_families, + ); + used_line_height_for_font(style, &font) + } pub fn is_empty(&self) -> bool { self.items.is_empty() } @@ -1158,6 +1196,32 @@ impl TextEngine { (width, height.max(item.forced_min_height)) } + /// Exact max-content size for one fallback word item. Paragraph IFCs keep + /// their historical integer-ceiled intrinsic width, but word boxes need + /// the selected webfont's fractional advance or every token accumulates a + /// pixel of horizontal drift against browser geometry. + pub(crate) fn measure_word(&mut self, idx: usize) -> (f32, f32) { + let TextEngine { + font_system, items, .. + } = self; + let Some(item) = items.get_mut(idx) else { + return (0.0, 0.0); + }; + shape_with_text_indent(font_system, item, None); + let mut width = 0.0f32; + let mut height = 0.0f32; + 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)); + height = height.max(run.line_top + run.line_height); + } + (width, height.max(item.forced_min_height)) + } + /// Register a replaced element's intrinsic size as a taffy measure /// context. Percentage-sized image leaves still need their intrinsic /// max-content contribution while an auto-sized ancestor is measured. diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e05109d..83abbfc 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -127,6 +127,37 @@ pub mod inline { ) -> Option { None } + + /// Layout-only builds do not load page fonts. Preserve the same + /// line-vs-fragment contract with deterministic bundled metrics. + pub(crate) fn inline_font_box_height(&self, style: &crate::LayoutStyle) -> f32 { + style.font_size.unwrap_or(16.0) + } + + pub(crate) fn selected_line_height(&self, style: &crate::LayoutStyle) -> f32 { + match style.line_height.unwrap_or(crate::LineHeight::Normal) { + crate::LineHeight::Normal => style.font_size.unwrap_or(16.0) * 1.2, + crate::LineHeight::Ratio(value) => style.font_size.unwrap_or(16.0) * value, + crate::LineHeight::Px(value) => value, + crate::LineHeight::Relative(crate::Dimension::Percent(value)) => { + style.font_size.unwrap_or(16.0) * value + } + crate::LineHeight::Relative(crate::Dimension::Px(value)) => value, + crate::LineHeight::Relative(_) => style.font_size.unwrap_or(16.0), + } + } + + pub(crate) fn push_generated_text( + &mut self, + _text: &str, + _style: &crate::LayoutStyle, + ) -> Option { + None + } + + pub(crate) fn measure_word(&mut self, _idx: usize) -> (f32, f32) { + (0.0, 0.0) + } } } @@ -1463,6 +1494,19 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { s.margin = rect_auto(style.margin, style.margin_auto); s.padding = rect_lp_percent(style.padding, style.padding_percent); s.border = rect_lp(style.border); + if style.ignores_used_box_sizes() { + // Block-axis margins on an ordinary non-replaced inline neither move + // nor size its fragment. Padding and border do paint around the raw + // font box, but they protrude outside the CSS line-height rather than + // increasing line advance. Keep them in LayoutStyle for fragment + // synthesis/paint and remove them only from this Taffy line surrogate. + s.margin.top = taffy::LengthPercentageAuto::length(0.0); + s.margin.bottom = taffy::LengthPercentageAuto::length(0.0); + s.padding.top = taffy::LengthPercentage::length(0.0); + s.padding.bottom = taffy::LengthPercentage::length(0.0); + s.border.top = taffy::LengthPercentage::length(0.0); + s.border.bottom = taffy::LengthPercentage::length(0.0); + } s } diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 91ac45a..85b273f 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -479,11 +479,37 @@ impl PreparedRender { "line-height", match style.line_height.unwrap_or(crate::LineHeight::Normal) { crate::LineHeight::Normal => "normal".to_string(), - crate::LineHeight::Ratio(value) => css_number(value), - crate::LineHeight::Px(value) => css_px(value), - crate::LineHeight::Relative(value) => dimension_css(value, "normal"), + _ => css_px(self.layout.text_engine.selected_line_height(style)), }, ); + out.insert( + "letter-spacing", + match style.letter_spacing.unwrap_or(0.0) { + value if value == 0.0 => "normal".to_string(), + value => css_px(value), + }, + ); + out.insert( + "white-space", + match style.white_space.unwrap_or_default() { + crate::WhiteSpace::Normal => "normal", + crate::WhiteSpace::NoWrap => "nowrap", + crate::WhiteSpace::Pre => "pre", + crate::WhiteSpace::PreWrap => "pre-wrap", + crate::WhiteSpace::PreLine => "pre-line", + crate::WhiteSpace::BreakSpaces => "break-spaces", + } + .to_string(), + ); + out.insert( + "text-align", + match style.text_align { + Some(taffy::AlignItems::CENTER) => "center", + Some(taffy::AlignItems::FLEX_END | taffy::AlignItems::END) => "end", + _ => "start", + } + .to_string(), + ); if style.ignores_used_box_sizes() { out.insert("width", dimension_css(style.width, "auto")); @@ -677,6 +703,49 @@ impl PreparedRender { Some(rect) } + /// Every CSS border-box fragment in the current root viewport. Ordinary + /// inlines can have one fragment per line; all other boxes expose their + /// single border box. Keeping this separate from the bounding union is + /// required by CSSOM View's `getClientRects()`. + pub fn viewport_client_rects( + &self, + id: obscura_dom::tree::NodeId, + requested_scroll: (f32, f32), + ) -> Option> { + let source = self + .layout + .inline_fragments + .get(&id) + .cloned() + .or_else(|| self.layout.rects.get(&id).copied().map(|rect| vec![rect]))?; + let transform = self + .layout + .translates + .get(&id) + .copied() + .unwrap_or((0.0, 0.0)); + let scroll = self.clamp_scroll(requested_scroll); + let movement = if self.viewport_fixed.contains(&id) { + transform + } else { + let sticky = self.sticky.translation_for(id, self.viewport, scroll); + ( + transform.0 + sticky.0 - scroll.0, + transform.1 + sticky.1 - scroll.1, + ) + }; + Some( + source + .into_iter() + .map(|rect| crate::Rect { + x: rect.x + movement.0, + y: rect.y + movement.1, + ..rect + }) + .collect(), + ) + } + pub fn selected_image( &self, id: obscura_dom::tree::NodeId, @@ -1183,7 +1252,16 @@ fn paint_laid_dom_scrolled( }; if node.is_text() { - paint_text_node(tree, nid, laid, &scroll_state, &mut pixmap); + if let Some(items) = laid.word_ifc_items.get(&nid) { + let offset = scroll_state.translation_for(laid, nid); + let clip = scroll_state.shaped_text_clip_for(laid, nid); + for &item in items { + laid.text_engine + .paint_item_with_clip(item, &mut pixmap, offset, clip); + } + } else { + paint_text_node(tree, nid, laid, &scroll_state, &mut pixmap); + } for generated in &generated_after_at[paint_index] { paint_in_flow_generated_box( &mut pixmap, diff --git a/render-repros/paired-corpus.py b/render-repros/paired-corpus.py index 9e8a249..b344aee 100755 --- a/render-repros/paired-corpus.py +++ b/render-repros/paired-corpus.py @@ -96,6 +96,9 @@ def geometry_probe_javascript(selectors_expression): "margin_bottom:style.marginBottom,margin_left:style.marginLeft," "padding_top:style.paddingTop,padding_right:style.paddingRight," "padding_bottom:style.paddingBottom,padding_left:style.paddingLeft," + "font_family:style.fontFamily,font_size:style.fontSize," + "font_weight:style.fontWeight,line_height:style.lineHeight," + "letter_spacing:style.letterSpacing,white_space:style.whiteSpace," "flex_direction:style.flexDirection," "align_items:style.alignItems,justify_content:style.justifyContent," "text_align:style.textAlign,contain:style.contain," diff --git a/render-repros/test_paired_corpus.py b/render-repros/test_paired_corpus.py index 1690c2b..8beebb7 100644 --- a/render-repros/test_paired_corpus.py +++ b/render-repros/test_paired_corpus.py @@ -344,6 +344,9 @@ class GeometryProbeTests(unittest.TestCase): self.assertEqual(args, (selectors,)) self.assertIn("querySelectorAll(selector)", expression) self.assertIn("catch(error)", expression) + self.assertIn("font_family:style.fontFamily", expression) + self.assertIn("line_height:style.lineHeight", expression) + self.assertIn("white_space:style.whiteSpace", expression) self.assertIn("geometry_probes: geometryProbes", expression) self.assertIn( f'sampled_phase: "{paired_corpus.CAPTURE_BOUNDARY_PHASE}"',