//! 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, HashSet}; 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, family: Option<&str>) -> f32 { crate::paint::measure_text(text, size, is_bold, family) } #[cfg(not(feature = "paint"))] fn text_width(text: &str, size: f32, is_bold: bool, _family: Option<&str>) -> 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>, /// 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. pub(crate) generated_boxes: Vec, } /// Root-scroll sticky-position constraints captured from normal-flow layout. /// /// The normal boxes stay immutable in the layout cache. A scroll offset is /// resolved into one accumulated translation per affected node, which keeps /// JS geometry and screenshot paint on the same path. #[derive(Debug, Clone, Default)] pub struct StickyLayout { frames: Vec, owners: HashMap, clip_owners: HashMap, } #[derive(Debug, Clone, Copy)] struct StickyFrame { id: NodeId, parent_sticky: Option, normal: Rect, containing: Rect, margin: crate::Edges, inset: [Option; 4], } impl StickyLayout { fn frame_offsets( &self, viewport: (f32, f32), scroll: (f32, f32), ) -> HashMap { let mut frame_offsets = HashMap::with_capacity(self.frames.len()); for frame in &self.frames { let inherited = frame .parent_sticky .and_then(|id| frame_offsets.get(&id).copied()) .unwrap_or((0.0, 0.0)); let normal = Rect { x: frame.normal.x + inherited.0, y: frame.normal.y + inherited.1, ..frame.normal }; let containing = Rect { x: frame.containing.x + inherited.0, y: frame.containing.y + inherited.1, ..frame.containing }; let x = sticky_axis_position( normal.x, normal.width, containing.x + frame.margin.left, containing.x + containing.width - frame.margin.right - normal.width, scroll.0, viewport.0, frame.inset[3], frame.inset[1], ); let y = sticky_axis_position( normal.y, normal.height, containing.y + frame.margin.top, containing.y + containing.height - frame.margin.bottom - normal.height, scroll.1, viewport.1, frame.inset[0], frame.inset[2], ); frame_offsets.insert( frame.id, (inherited.0 + x - normal.x, inherited.1 + y - normal.y), ); } frame_offsets } /// Resolve a single geometry query without materializing an entry for /// every descendant in every sticky subtree. This is O(sticky frames), not /// O(DOM), and is the hot path for repeated getBoundingClientRect reads. pub fn translation_for( &self, id: NodeId, viewport: (f32, f32), scroll: (f32, f32), ) -> (f32, f32) { let Some(owner) = self.owners.get(&id) else { return (0.0, 0.0); }; self.frame_offsets(viewport, scroll) .get(owner) .copied() .unwrap_or((0.0, 0.0)) } /// Accumulated sticky translation for every node in a sticky subtree. /// Frames are stored in DOM preorder, so an outer sticky frame's resolved /// movement is available before a nested sticky frame is constrained. /// Paint calls this once for the whole document; geometry uses /// [`StickyLayout::translation_for`] to avoid an O(DOM) map per query. pub fn translations( &self, viewport: (f32, f32), scroll: (f32, f32), ) -> HashMap { let frame_offsets = self.frame_offsets(viewport, scroll); self.owners .iter() .filter_map(|(&id, owner)| { frame_offsets.get(owner).copied().map(|offset| (id, offset)) }) .collect() } /// Sticky-space movement of the ancestor that owns a node's inherited /// overflow clip. A sticky descendant must move inside an outer clip, /// while a clip established inside the sticky subtree moves with it. pub fn clip_translations_from( &self, translations: &HashMap, ) -> HashMap { self.clip_owners .iter() .filter_map(|(&id, owner)| translations.get(owner).copied().map(|offset| (id, offset))) .collect() } pub fn is_empty(&self) -> bool { self.frames.is_empty() } } fn sticky_axis_position( normal: f32, size: f32, contain_min: f32, contain_max: f32, scroll: f32, viewport: f32, start: Option, end: Option, ) -> f32 { let mut stick_start = start.map(|inset| scroll + inset); let mut stick_end = end.map(|inset| scroll + viewport - inset - size); // When both insets leave a sticky view rectangle smaller than the box, // the end inset is reduced so the start edge wins in the start direction. if let (Some(start), Some(end)) = (stick_start, stick_end) { if end < start { stick_end = Some(start); } } let mut position = normal; if let Some(start) = stick_start.take() { position = position.max(start.min(contain_max)); } if let Some(end) = stick_end { position = position.min(end.max(contain_min)); } position } impl DomLayout { /// Nodes whose painted coordinate space is anchored to the initial /// containing block. A viewport-fixed element and its whole subtree stay /// stationary when the document scrolls. A fixed element captured by a /// transformed/filter/contain ancestor remains in the document scrolling /// coordinate space instead. pub fn viewport_fixed_nodes(&self, tree: &DomTree) -> HashSet { let mut fixed = HashSet::new(); let mut has_fixed_cb: HashMap = HashMap::new(); for id in tree.descendants(tree.document()) { let parent = tree.get_node(id).and_then(|node| node.parent); let parent_is_fixed = parent.is_some_and(|parent| fixed.contains(&parent)); let ancestor_has_fixed_cb = parent .and_then(|parent| has_fixed_cb.get(&parent).copied()) .unwrap_or(false); let style = self.styles.get(&id); let starts_viewport_fixed = style.is_some_and(|style| style.position_fixed && !ancestor_has_fixed_cb); if parent_is_fixed || starts_viewport_fixed { fixed.insert(id); } let establishes_fixed_cb = style.is_some_and(|style| style.containing_block_triggers != 0); has_fixed_cb.insert(id, ancestor_has_fixed_cb || establishes_fixed_cb); } fixed } /// Root scrolling overflow in CSS pixels. This is the union of laid-out /// document boxes after translate transforms, bounded by inherited /// overflow clips. Viewport-fixed subtrees do not enlarge the document. pub fn scrolling_content_size(&self, tree: &DomTree, viewport: (f32, f32)) -> (f32, f32) { let fixed = self.viewport_fixed_nodes(tree); let mut right = viewport.0.max(0.0); let mut bottom = viewport.1.max(0.0); for (&id, rect) in &self.rects { if fixed.contains(&id) { continue; } let (tx, ty) = self.translates.get(&id).copied().unwrap_or((0.0, 0.0)); let mut painted = Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height, }; if let Some(clip) = self.clip_rects.get(&id).copied().flatten() { let Some(clipped) = painted.intersect(&clip) else { continue; }; painted = clipped; } right = right.max(painted.x + painted.width); bottom = bottom.max(painted.y + painted.height); } (right.ceil(), bottom.ceil()) } /// Capture root/window-scroll sticky constraints from the normal-flow /// result. Nested overflow scrollers are intentionally outside this slice; /// their sticky frames will need a scroll-container-specific instance. pub fn root_sticky_layout(&self, tree: &DomTree, viewport: (f32, f32)) -> StickyLayout { let viewport_fixed = self.viewport_fixed_nodes(tree); let content = self.scrolling_content_size(tree, viewport); let root_containing = Rect { x: 0.0, y: 0.0, width: content.0, height: content.1, }; let mut layout = StickyLayout::default(); let mut nearest_sticky: HashMap> = HashMap::new(); let mut inherited_clip_sticky: HashMap> = HashMap::new(); let mut inside_nested_scroller: HashMap = HashMap::new(); for id in tree.descendants(tree.document()) { let parent = tree.get_node(id).and_then(|node| node.parent); let has_nested_scroll_container = parent.is_some_and(|parent| { inside_nested_scroller.get(&parent).copied().unwrap_or(false) || self .styles .get(&parent) .is_some_and(|style| { let is_root_propagated_overflow = tree .get_node(parent) .is_some_and(|node| { node.as_element().is_some_and(|element| { matches!(element.local.as_ref(), "html" | "body") }) }); style.overflow_scroll_container && !is_root_propagated_overflow }) }); inside_nested_scroller.insert(id, has_nested_scroll_container); let parent_sticky = parent .and_then(|parent| nearest_sticky.get(&parent).copied()) .flatten(); let inherited_clip = parent .and_then(|parent| { let parent_style = self.styles.get(&parent); if parent_style.is_some_and(|style| style.overflow_hidden) { nearest_sticky.get(&parent).copied().flatten() } else { inherited_clip_sticky.get(&parent).copied().flatten() } }); if let Some(owner) = inherited_clip { layout.clip_owners.insert(id, owner); } inherited_clip_sticky.insert(id, inherited_clip); if viewport_fixed.contains(&id) { nearest_sticky.insert(id, None); continue; } let style = self.styles.get(&id); let is_sticky = style.is_some_and(|style| { style.position_sticky && style.inset.iter().any(Option::is_some) && !has_nested_scroll_container }); if !is_sticky { nearest_sticky.insert(id, parent_sticky); if let Some(owner) = parent_sticky { layout.owners.insert(id, owner); } continue; } let (Some(style), Some(rect)) = (style, self.rects.get(&id).copied()) else { nearest_sticky.insert(id, parent_sticky); continue; }; let (tx, ty) = self.translates.get(&id).copied().unwrap_or((0.0, 0.0)); let normal = Rect { x: rect.x + tx, y: rect.y + ty, ..rect }; let mut containing = None; let mut ancestor = parent; while let Some(candidate) = ancestor { if let (Some(candidate_style), Some(candidate_rect)) = ( self.styles.get(&candidate), self.rects.get(&candidate).copied(), ) { if candidate_style.display != crate::Display::Inline && !candidate_style.display_contents { let (ctx, cty) = self.translates.get(&candidate).copied().unwrap_or((0.0, 0.0)); containing = Some(Rect { x: candidate_rect.x + ctx + candidate_style.border.left + candidate_style.padding.left, y: candidate_rect.y + cty + candidate_style.border.top + candidate_style.padding.top, width: (candidate_rect.width - candidate_style.border.left - candidate_style.border.right - candidate_style.padding.left - candidate_style.padding.right) .max(0.0), height: (candidate_rect.height - candidate_style.border.top - candidate_style.border.bottom - candidate_style.padding.top - candidate_style.padding.bottom) .max(0.0), }); break; } } ancestor = tree.get_node(candidate).and_then(|node| node.parent); } let resolve_inset = |value: Option, basis: f32| match value { Some(crate::Dimension::Px(px)) => Some(px), Some(crate::Dimension::Percent(percent)) => Some(percent * basis), _ => None, }; let inset = [ resolve_inset(style.inset[0], viewport.1), resolve_inset(style.inset[1], viewport.0), resolve_inset(style.inset[2], viewport.1), resolve_inset(style.inset[3], viewport.0), ]; layout.frames.push(StickyFrame { id, parent_sticky, normal, containing: containing.unwrap_or(root_containing), margin: style.margin, inset, }); layout.owners.insert(id, id); nearest_sticky.insert(id, Some(id)); } layout } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GeneratedBoxKind { Before, After, } #[derive(Clone, Copy)] pub(crate) struct GeneratedBox { pub host: NodeId, pub kind: GeneratedBoxKind, pub rect: Rect, } #[derive(Clone, Copy)] struct GeneratedBoxBuild { host: NodeId, kind: GeneratedBoxKind, node: taffy::NodeId, } /// 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>, generated: Vec, /// 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>)>, /// Minimum row heights per table grid node, one entry per source row. /// The post-width row-sizing pass combines these with each cell's final /// content height and pins the tracks before vertical alignment. table_rows: HashMap>>, /// Floats whose direct block parent does not establish a block formatting /// context and whose exclusion can therefore continue through later /// descendant blocks of the same BFC. The first layout pass gives these /// nodes real geometry; `apply_float_continuations` uses it to narrow the /// later intersecting bands before the final layout. float_continuations: Vec, /// CSS multi-column containers built as a row of anonymous block /// fragmentainers. All in-flow boxes start in the first fragmentainer so /// the preliminary layout measures them at the final column width; the /// post-pass then balances those already-built boxes without rebuilding /// or reshaping their subtrees. multicol: Vec, } struct MulticolBuild { columns: Vec, children: Vec, } #[derive(Clone, Copy)] struct FloatContinuation { owner: NodeId, float: taffy::NodeId, flow: taffy::NodeId, side: crate::Float, } /// 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 = overflow_clip_rect(rect, style, tx, ty); 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); } } /// The CSS overflow clip is the padding box, not the outer border box. This is /// also the coordinate-space boundary Gecko captures for descendant display /// items: transformed content may cover the padding area but must not repaint /// its clip owner's border. fn overflow_clip_rect(rect: &Rect, style: &crate::LayoutStyle, tx: f32, ty: f32) -> Rect { Rect { x: rect.x + tx + style.border.left, y: rect.y + ty + style.border.top, width: (rect.width - style.border.left - style.border.right).max(0.0), height: (rect.height - style.border.top - style.border.bottom).max(0.0), } } /// 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::Ex(v) => v * 16.0 * 0.528_320_3, 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(valign) = node.get_attribute("valign") { crate::style::apply_inline(style, &format!("vertical-align: {}", valign)); } 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); } } } // An inline SVG with one CSS axis and a viewBox derives the other axis // from the viewBox's intrinsic aspect ratio. Framework logos commonly set // only a responsive utility width (`w-24 lg:w-28`) and rely on this rule; // without it the SVG border box had zero height and paint skipped it. if style.aspect_ratio.is_none() && node .as_element() .map_or(false, |element| element.local.as_ref() == "svg") { if let Some(view_box) = node .get_attribute("viewBox") .or_else(|| node.get_attribute("viewbox")) { let values: Vec = view_box .split(|c: char| c.is_ascii_whitespace() || c == ',') .filter(|part| !part.is_empty()) .filter_map(|part| part.parse::().ok()) .collect(); if values.len() == 4 && values[2] > 0.0 && values[3] > 0.0 { style.aspect_ratio = Some(values[2] / values[3]); } } } } /// The selected `` contributes presentation hints to its /// associated ``. In particular, source width/height replace the fallback /// image attributes and provide the pre-load aspect ratio. Gecko exposes this /// as an extra mapped declaration block on HTMLImageElement; doing the same /// before the author cascade preserves the correct origin and precedence. fn apply_picture_source_hints( tree: &DomTree, img_id: NodeId, viewport: (f32, f32), style: &mut crate::LayoutStyle, ) { let Some(img) = tree.get_node(img_id) else { return }; if img .as_element() .map_or(true, |element| element.local.as_ref() != "img") { return; } let Some(parent_id) = img.parent else { return }; let is_picture = tree .get_node(parent_id) .is_some_and(|parent| { parent .as_element() .is_some_and(|element| element.local.as_ref() == "picture") }); if !is_picture { return; } let mut selected = None; for child_id in tree.children(parent_id) { if child_id == img_id { break; } let Some(source) = tree.get_node(child_id) else { continue }; if source .as_element() .map_or(true, |element| element.local.as_ref() != "source") { continue; } if source .get_attribute("srcset") .is_none_or(|srcset| srcset.trim().is_empty()) { continue; } if let Some(media) = source.get_attribute("media") { if !media.trim().is_empty() && !crate::css::media_query_applies_for_viewport(media, viewport) { continue; } } if let Some(kind) = source.get_attribute("type") { let kind = kind.trim().to_ascii_lowercase(); if !kind.is_empty() && !matches!( kind.as_str(), "image/avif" | "image/bmp" | "image/gif" | "image/jpeg" | "image/jpg" | "image/png" | "image/svg+xml" | "image/webp" | "image/x-icon" | "image/vnd.microsoft.icon" ) { continue; } } selected = Some(source); break; } let Some(source) = selected else { return }; let parse_dimension = |name| { source .get_attribute(name) .and_then(|value| value.trim().parse::().ok()) .filter(|value| value.is_finite() && *value > 0.0) }; let width = parse_dimension("width"); let height = parse_dimension("height"); if width.is_none() && height.is_none() { return; } // A missing source dimension explicitly maps to auto so it replaces the // corresponding fallback presentation hint. style.width = width.map_or(crate::Dimension::Auto, crate::Dimension::Px); style.height = height.map_or(crate::Dimension::Auto, crate::Dimension::Px); style.width_set = true; style.height_set = true; style.aspect_ratio = match (width, height) { (Some(width), Some(height)) => Some(width / height), _ => None, }; } /// 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>, quirks_mode: bool, viewport: (f32, f32), inherited_cell_padding: Option, inherited_color_scheme_dark: bool, ) { 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(); let mut descendant_cell_padding = inherited_cell_padding; let mut descendant_color_scheme_dark = inherited_color_scheme_dark; if let Some(elem) = node.as_element() { if elem.local.as_ref() == "table" { // `cellpadding` is a table-scoped presentational hint applied to // its cells, below author CSS. Entering any nested table resets // the outer table's value before reading the nested attribute. descendant_cell_padding = node .get_attribute("cellpadding") .and_then(|value| value.trim().parse::().ok()) .filter(|value| value.is_finite() && *value >= 0.0); } let mut style = crate::style::ua_style(elem.local.as_ref()); style.color_scheme_dark = inherited_color_scheme_dark; if matches!(elem.local.as_ref(), "td" | "th") { if let Some(padding) = inherited_cell_padding { style.padding = crate::Edges { top: padding, right: padding, bottom: padding, left: padding, }; } } if quirks_mode && elem.local.as_ref() == "form" { // Legacy HTML/quirks rendering keeps one em after forms. Standards // mode does not; Hacker News and many old document templates omit // a doctype and rely on this spacing. style.margin_relative[2] = Some(crate::Dimension::Em(1.0)); } if elem.local.as_ref() == "input" { let input_type = node .get_attribute("type") .unwrap_or("text") .trim() .to_ascii_lowercase(); if quirks_mode { style.box_sizing = crate::BoxSizing::BorderBox; } match input_type.as_str() { "checkbox" | "radio" => { style.margin = crate::Edges { top: 3.0, right: 3.0, bottom: 3.0, left: 4.0, }; style.padding = crate::Edges::default(); style.border = crate::Edges::default(); } "range" | "color" => { style.margin = crate::Edges { top: 2.0, right: 2.0, bottom: 2.0, left: 2.0, }; style.padding = crate::Edges::default(); style.border = crate::Edges::default(); } _ => {} } } // 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); apply_picture_source_hints(tree, id, viewport, &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_pseudo, after_pseudo) = sheet.pseudo_styles(tree, matcher, id, &this_props, &style); style.before_content = before_pseudo .as_ref() .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) .and_then(|pseudo| pseudo.before_content.clone()); style.after_content = after_pseudo .as_ref() .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) .and_then(|pseudo| pseudo.before_content.clone()); style.before_pseudo = before_pseudo.map(Box::new); style.after_pseudo = after_pseudo.map(Box::new); descendant_color_scheme_dark = style.color_scheme_dark; 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, quirks_mode, viewport, descendant_cell_padding, descendant_color_scheme_dark, ); } 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 { layout_dom_with_resources(tree, viewport, intrinsic, &[]) } /// Like [`layout_dom_with_images`], with decoded OpenType web-font data loaded /// into the shaping database for this render pass. pub fn layout_dom_with_resources( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[Vec], ) -> DomLayout { let fonts: Vec<_> = fonts .iter() .map(|data| crate::inline::WebFont { data: data.clone(), family: None, weight: None, italic: None, }) .collect(); layout_dom_with_web_fonts(tree, viewport, intrinsic, &fonts) } pub(crate) fn layout_dom_with_web_fonts( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[crate::inline::WebFont], ) -> DomLayout { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); // Collect the text of every \
\
\
\
\
\
\
\
\
\
", ); let laid = layout_dom(&tree, (800.0, 600.0)); let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; let columns = rect("columns"); assert!((columns.width - 630.0).abs() < 0.1, "{columns:?}"); assert!((columns.height - 300.0).abs() < 0.1, "{columns:?}"); let expected = [ ("a", 0.0, 0.0, 190.0, 180.0), ("b", 0.0, 180.0, 190.0, 120.0), ("c", 220.0, 0.0, 190.0, 60.0), ("d", 220.0, 60.0, 190.0, 150.0), ("e", 220.0, 210.0, 190.0, 90.0), ("f", 440.0, 0.0, 190.0, 120.0), ("g", 440.0, 120.0, 190.0, 80.0), ("h", 440.0, 200.0, 190.0, 100.0), ]; for (id, x, y, width, height) in expected { let child = rect(id); assert!((child.x - columns.x - x).abs() < 0.1, "{id}: {child:?}"); assert!((child.y - columns.y - y).abs() < 0.1, "{id}: {child:?}"); assert!((child.width - width).abs() < 0.1, "{id}: {child:?}"); assert!((child.height - height).abs() < 0.1, "{id}: {child:?}"); } let narrow = layout_dom(&tree, (600.0, 1000.0)); let columns_id = tree.get_element_by_id("columns").unwrap(); let c_id = tree.get_element_by_id("c").unwrap(); assert_eq!(narrow.styles[&columns_id].column_count, Some(1)); assert!( (narrow.rects[&columns_id].height - 900.0).abs() < 0.1, "{:?}", narrow.rects[&columns_id] ); assert!( (narrow.rects[&c_id].y - narrow.rects[&columns_id].y - 300.0).abs() < 0.1, "{:?}", narrow.rects[&c_id] ); } #[test] fn multicol_does_not_atomize_breakable_prose_boxes() { let tree = parse_html( "
\

\

\
", ); let laid = layout_dom(&tree, (400.0, 400.0)); let columns = laid.rects[&tree.get_element_by_id("columns").unwrap()]; let first = laid.rects[&tree.get_element_by_id("first").unwrap()]; let second = laid.rects[&tree.get_element_by_id("second").unwrap()]; assert!((columns.height - 200.0).abs() < 0.1, "{columns:?}"); assert!((first.width - 200.0).abs() < 0.1, "{first:?}"); assert!((second.x - first.x).abs() < 0.1, "{second:?}"); assert!((second.y - first.y - 100.0).abs() < 0.1, "{second:?}"); } #[test] fn empty_document_is_safe() { // html5ever always synthesizes html/head/body, so an empty document // still has a few element rects. The point is that it does not panic. let tree = parse_html(""); let laid = layout_dom(&tree, (1280.0, 720.0)); assert!(laid.rects.len() <= 4, "got {}", laid.rects.len()); } #[test] fn author_cascade_honors_important_and_presentational_hint_order() { let tree = parse_html( r#"
"#, ); 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 root_percentage_font_size_controls_rem_lengths() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let html = tree.query_selector("html").unwrap().unwrap(); let box_id = tree.query_selector("#box").unwrap().unwrap(); assert_eq!(laid.styles.get(&html).unwrap().font_size, Some(10.0)); assert_eq!(laid.styles.get(&box_id).unwrap().row_gap, Some(40.0)); assert_eq!(laid.styles.get(&box_id).unwrap().column_gap, Some(35.6)); assert_eq!(laid.rects.get(&box_id).unwrap().width, 40.0); assert_eq!(laid.rects.get(&box_id).unwrap().height, 80.0); assert_eq!(laid.rects.get(&box_id).unwrap().y, 30.0); } #[test] fn block_and_grid_sibling_margins_collapse() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let b = tree.query_selector("#b").unwrap().unwrap(); assert_eq!(laid.rects.get(&b).unwrap().y, 50.0); } #[test] fn text_transform_applies_to_word_leaves_in_flex_ui() { let tree = parse_html( r#"
get started
"#, ); let text = tree .descendants(tree.document()) .into_iter() .find(|id| tree.get_node(*id).is_some_and(|node| node.is_text())) .unwrap(); let laid = layout_dom(&tree, (1280.0, 720.0)); let painted = laid .text_runs .get(&text) .unwrap() .iter() .map(|(_, word)| word.as_str()) .collect::(); assert_eq!(painted, "GET STARTED"); assert_eq!( transform_word_leaf_text("hELLO wORLD", crate::TextTransform::Capitalize), "HELLO WORLD" ); } #[test] fn word_leaves_use_the_computed_line_height() { let tree = parse_html( r#"
code
"#, ); let text = tree .descendants(tree.document()) .into_iter() .find(|id| tree.get_node(*id).is_some_and(|node| node.is_text())) .unwrap(); let laid = layout_dom(&tree, (1280.0, 720.0)); let runs = laid.text_runs.get(&text).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].0.height, 32.0); } #[test] fn light_root_rejects_dark_pseudo_gradient_variants() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let hero = tree.query_selector(".hero").unwrap().unwrap(); let pseudo = laid.styles[&hero].before_pseudo.as_ref().unwrap(); let (_, stops) = pseudo.background_radial_gradient.as_ref().unwrap(); assert_eq!(stops[0].0, [0xeb, 0xf3, 0xf9, 255]); assert_eq!(stops[1].0, [0xd6, 0xde, 0xe4, 255]); } #[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); } #[test] fn positioned_pseudo_inherits_the_hosts_computed_font_metrics() { let tree = parse_html( r#" "#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let cta = tree.get_element_by_id("cta").unwrap(); let host = &laid.styles[&cta]; let pseudo = host.after_pseudo.as_ref().unwrap(); assert_eq!(host.font_size, Some(14.0)); assert_eq!(pseudo.font_size, host.font_size); assert_eq!(pseudo.font_family, host.font_family); assert_eq!(pseudo.font_weight, host.font_weight); assert_eq!(pseudo.line_height, host.line_height); } #[test] fn native_select_sizes_for_its_widest_option() { let tree = parse_html( r#""#, ); let laid = layout_dom(&tree, (500.0, 200.0)); let select = tree.get_element_by_id("language").unwrap(); let rect = laid.rects[&select]; assert!(rect.width > 120.0, "widest option should set width: {rect:?}"); assert!( (18.0..=22.0).contains(&rect.height), "closed native select should have one-line control height: {rect:?}" ); } }