//! DOM integration: build a taffy layout tree from a live [`DomTree`], run //! layout, and return border-box geometry keyed by [`NodeId`]. //! //! Phase 3. Text nodes do not yet contribute to size (no inline/text layout //! until the text/paint phase), so a leaf element with only text may have zero //! height. Block and flex structure, plus explicit sizes and box model, are //! correct. use std::collections::HashMap; use obscura_dom::tree::{DomTree, NodeId}; use taffy::prelude::*; use crate::{Rect, to_taffy_style}; /// Text width for layout. With the `paint` feature this is exact (real glyph /// metrics from the embedded font, shared with rasterization). Without it /// (layout-only builds, e.g. for `getBoundingClientRect`), fall back to a /// standard average-character-width heuristic for proportional fonts so /// `obscura-render` compiles and gives reasonable geometry with its default /// (lightest-weight) feature set, matching RENDER.md's documented "layout /// (default)" mode. #[cfg(feature = "paint")] fn text_width(text: &str, size: f32, is_bold: bool) -> f32 { crate::paint::measure_text(text, size, is_bold) } #[cfg(not(feature = "paint"))] fn text_width(text: &str, size: f32, is_bold: bool) -> f32 { const AVG_CHAR_WIDTH_EM: f32 = 0.55; let chars = text.chars().filter(|c| !c.is_control()).count() as f32; let width = chars * size * AVG_CHAR_WIDTH_EM; if is_bold { width * 1.08 } else { width } } /// Per-element border boxes after layout, in viewport coordinates. pub struct DomLayout { pub rects: HashMap, pub styles: HashMap, /// The clip rect inherited from ancestor `overflow: hidden` boxes, keyed /// per node, in SCREEN space (the clip owner's box shifted by the owner's /// accumulated translate; see `resolve_clip_rects`). `None` means /// unclipped. Does not include the node's own overflow (that only clips /// its children, not itself). pub clip_rects: HashMap>, /// Accumulated `transform: translate()` per node (own + ancestors), in CSS /// px. Only nodes with a non-zero accumulation are present; paint shifts /// each box by this to reach screen space. pub translates: HashMap, /// Per-word geometry for text nodes: a text DOM node lays out as one /// taffy leaf per word (see `build_text_words`), each wrapping /// independently within its container, so its rendered content is a list /// of (box, word text) pairs rather than a single box, unlike every other /// node kind. Keyed by the text node's `NodeId`, in layout order. pub text_runs: HashMap>, /// Inline formatting contexts that were shaped by cosmic-text (see /// `inline`): a single leaf per container, its glyphs held in /// `text_engine`. `ifc_items` maps the container's `NodeId` to its item /// index. Paint rasterizes these instead of the per-word `text_runs`. #[cfg(feature = "paint")] pub text_engine: crate::inline::TextEngine, #[cfg(feature = "paint")] pub ifc_items: HashMap, /// Anonymous inline-run contexts: a mixed block's consecutive inline /// children folded to one shaped leaf each (see `build_mixed_block`). /// Keyed by the parent block's `NodeId`, in document order; the leaves /// have no DOM node of their own. #[cfg(feature = "paint")] pub run_ifc_items: HashMap>, } /// Registry of cosmic-text inline formatting contexts created during the /// taffy-tree build: `whole` holds single-leaf containers keyed by the /// container's own DOM id, `runs` holds anonymous inline-run leaves keyed by /// the parent block's DOM id (those leaves have no DOM node, so their final /// rects are captured separately; see `compute_absolute_rects`). #[derive(Default)] struct IfcRegistry { whole: HashMap, runs: HashMap>, /// Specified column widths per table grid node, from `` elements and /// colspan-1 cells: `(px, percent)` per column index. Consumed by the /// table column-balancing pass in `layout_dom_with_images`, which pins /// specified columns instead of sizing them purely from content. table_cols: HashMap>, Vec>)>, } /// Walk the tree top-down accumulating the clip rect imposed by ancestor /// `overflow: hidden` boxes. Must run after layout, since it needs border /// boxes. This is what makes `overflow: hidden` (used pervasively for the /// "visually hidden but accessible" pattern: a 1x1 absolutely-positioned box /// with clipped overflow) actually hide its contents instead of letting text /// paint wherever the box's static position happens to land. /// Clips are stored in SCREEN space: the clip owner's border box offset by the /// owner's own accumulated `transform: translate()`. A clip belongs to its /// owner's coordinate space, not the painted descendant's; shifting an /// inherited clip by the descendant's translate (the old behavior) let a /// carousel track's `translateX` drag the viewport's clip along with every /// slide, so all slides stayed visible inside their own shifted clip. /// /// The same walk records each node's accumulated translate in `translates` /// (one pass here instead of an ancestor walk per painted node). fn resolve_clip_rects( tree: &DomTree, id: NodeId, inherited: Option, tx: f32, ty: f32, rects: &HashMap, styles: &HashMap, clip_rects: &mut HashMap>, translates: &mut HashMap, ) { clip_rects.insert(id, inherited); // This node's own translate joins the accumulation for its box and its // whole subtree (percentages resolve against its own border box). let (tx, ty) = match (styles.get(&id).and_then(|s| s.transform_translate), rects.get(&id)) { (Some((dx, dy)), Some(rect)) => (tx + resolve_translate(dx, rect.width), ty + resolve_translate(dy, rect.height)), (Some((dx, dy)), None) => (tx + resolve_translate(dx, 0.0), ty + resolve_translate(dy, 0.0)), _ => (tx, ty), }; if tx != 0.0 || ty != 0.0 { translates.insert(id, (tx, ty)); } let next = match (styles.get(&id), rects.get(&id)) { (Some(style), Some(rect)) if style.overflow_hidden => { let own = Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height }; Some(match inherited { Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()), None => own, }) } _ => inherited, }; for cid in tree.children(id) { resolve_clip_rects(tree, cid, next, tx, ty, rects, styles, clip_rects, translates); } } /// Resolve one `transform: translate()` component to px: a length passes /// through, a percentage is taken against `basis` (the element's own /// border-box extent on that axis). Font/viewport-relative leftovers fall /// back to a coarse px value (translate rarely uses them). pub(crate) fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 { match d { crate::Dimension::Px(px) => px, crate::Dimension::Percent(p) => p * basis, crate::Dimension::Em(v) | crate::Dimension::Rem(v) => v * 16.0, crate::Dimension::Vw(v) | crate::Dimension::Vh(v) | crate::Dimension::Vmin(v) | crate::Dimension::Vmax(v) => v, crate::Dimension::Auto => 0.0, } } /// Apply HTML presentational attributes at their cascade origin: above the UA /// defaults, but below every author stylesheet and style attribute. fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate::LayoutStyle) { if let Some(color) = node.get_attribute("color") { crate::style::apply_inline(style, &format!("color: {}", color)); } if let Some(bgcolor) = node.get_attribute("bgcolor") { crate::style::apply_inline(style, &format!("background-color: {}", bgcolor)); } if let Some(width) = node.get_attribute("width") { if width.chars().all(|c| c.is_ascii_digit()) { crate::style::apply_inline(style, &format!("width: {}px", width)); } else { crate::style::apply_inline(style, &format!("width: {}", width)); } } if let Some(align) = node.get_attribute("align") { crate::style::apply_inline(style, &format!("text-align: {}", align)); } if let Some(cellspacing) = node.get_attribute("cellspacing") { if cellspacing.chars().all(|c| c.is_ascii_digit()) { crate::style::apply_inline(style, &format!("border-spacing: {}px", cellspacing)); } } if let Some(height) = node.get_attribute("height") { if height.chars().all(|c| c.is_ascii_digit()) { crate::style::apply_inline(style, &format!("height: {}px", height)); } else { crate::style::apply_inline(style, &format!("height: {}", height)); } } if style.aspect_ratio.is_none() { let aw = node.get_attribute("width").and_then(|w| w.parse::().ok()); let ah = node.get_attribute("height").and_then(|h| h.parse::().ok()); if let (Some(w), Some(h)) = (aw, ah) { if w > 0.0 && h > 0.0 { style.aspect_ratio = Some(w / h); } } } } /// Compute the UA + author style for every element in preorder, maintaining /// `matcher`'s ancestor filter as we descend so descendant-combinator rules /// fast-reject correctly. Non-element nodes (text, comments) are skipped but /// still walked through, since an element may be their descendant. fn cascade_walk( tree: &DomTree, id: NodeId, sheet: &crate::css::Stylesheet, matcher: &mut obscura_dom::selector::Matcher, styles: &mut HashMap, parent_props: &std::rc::Rc>, ) { let Some(node) = tree.get_node(id) else { return }; let is_element = node.is_element(); // The custom-property map in force for this node's subtree: the parent's, // unless this element declares its own `--x` (then a richer map). let mut this_props = parent_props.clone(); if let Some(elem) = node.as_element() { let mut style = crate::style::ua_style(elem.local.as_ref()); // UA rule `[hidden]:not([hidden=until-found]) { display: none }`. // Applied before the author cascade so a matching author `display` // still wins (UA origin, per the HTML rendering spec). Sites ship // dialogs, tab panels, and menus with a plain `hidden` attribute and // reveal them by removing it (eBay's homepage interstitial uses // `.lightbox-dialog[role=dialog]:not([hidden]){display:flex}`), so // without this the hidden overlay paints its full-page dark backdrop. if let Some(h) = node.get_attribute("hidden") { if !h.eq_ignore_ascii_case("until-found") { style.display = crate::Display::None; } } apply_presentational_hints(&node, &mut style); let node_id = node.get_attribute("id"); let classes: Vec = node .get_attribute("class") .map(|c| c.split_whitespace().map(|s| s.to_string()).collect()) .unwrap_or_default(); if let Some(m) = sheet.apply( tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style, parent_props, node.get_attribute("style"), ) { this_props = std::rc::Rc::new(m); } let (before, after) = sheet.pseudo_content(tree, matcher, id); style.before_content = before; style.after_content = after; styles.insert(id, style); } if is_element { matcher.push_ancestor(tree, id); } for cid in tree.children(id) { cascade_walk(tree, cid, sheet, matcher, styles, &this_props); } if is_element { matcher.pop_ancestor(); } } /// Lay out a DOM tree within `viewport` (width, height) in CSS pixels. pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { layout_dom_with_images(tree, viewport, &HashMap::new()) } /// Like [`layout_dom`], but `intrinsic` supplies fetched intrinsic pixel sizes /// (width, height) for replaced elements keyed by `NodeId`. Paint collects /// these before layout (it has the base URL and the image cache) so a /// CSS-sized `` with no width/height attribute gets a real box from its /// intrinsic ratio instead of collapsing to zero area. pub fn layout_dom_with_images( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, ) -> DomLayout { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); // Collect the text of every
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); for id in ["sheet", "inline-normal", "inline-important", "inline-order", "custom"] { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); let style = laid.styles.get(&nid).unwrap(); assert_eq!(style.width, crate::Dimension::Px(320.0), "wrong cascade width for {id}"); } for id in ["sheet", "inline-normal", "inline-important", "inline-order", "hint"] { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); let style = laid.styles.get(&nid).unwrap(); assert_eq!(style.background_color, Some([0, 128, 0, 255]), "wrong cascade color for {id}"); } } #[test] fn box_sizing_controls_the_declared_size_edge() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let size = |id: &str| { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); let rect = laid.rects.get(&nid).unwrap(); (rect.width, rect.height) }; assert_eq!(size("content"), (124.0, 74.0)); assert_eq!(size("border"), (100.0, 50.0)); assert_eq!(size("half-content").0, 224.0); assert_eq!(size("half-border").0, 200.0); assert_eq!(size("max-content").0, 124.0); assert_eq!(size("max-border").0, 100.0); assert_eq!(size("inherited-border").0, 100.0); } #[test] fn table_cell_content_box_width_includes_padding_and_border_in_track() { let tree = parse_html( r#"
content
border
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let width = |id: &str| { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); laid.rects.get(&nid).unwrap().width }; assert_eq!(width("content-cell"), 124.0); assert_eq!(width("border-cell"), 100.0); } }