//! 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>, letter_spacing: f32, ) -> f32 { crate::paint::measure_text(text, size, is_bold, family) + text.chars().filter(|c| !c.is_control()).count() as f32 * letter_spacing } #[cfg(not(feature = "paint"))] fn text_width( text: &str, size: f32, is_bold: bool, _family: Option<&str>, letter_spacing: f32, ) -> f32 { const AVG_CHAR_WIDTH_EM: f32 = 0.55; let chars = text.chars().filter(|c| !c.is_control()).count() as f32; let glyph_width = chars * size * AVG_CHAR_WIDTH_EM; (if is_bold { glyph_width * 1.08 } else { glyph_width }) + chars * letter_spacing } /// 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); if let Some(root) = tree .descendants(tree.document()) .into_iter() .find(|id| tree.get_node(*id).is_some_and(|node| node.is_element())) { accumulate_scrolling_overflow( tree, root, None, &fixed, &self.rects, &self.styles, &self.translates, &mut right, &mut bottom, ); } (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)); // Sticky constraints are solved from the box's normal-flow // position, before its own transform is painted. `translates` // carries the accumulated transform chain, so remove exactly this // frame's resolved translate while retaining every ancestor's. let (own_tx, own_ty) = style .transform_translate .map(|(x, y)| { ( resolve_translate(x, rect.width), resolve_translate(y, rect.height), ) }) .unwrap_or((0.0, 0.0)); let normal = Rect { x: rect.x + tx - own_tx, y: rect.y + ty - own_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 } } #[allow(clippy::too_many_arguments)] fn accumulate_scrolling_overflow( tree: &DomTree, id: NodeId, inherited_clip: Option, fixed: &HashSet, rects: &HashMap, styles: &HashMap, translates: &HashMap, right: &mut f32, bottom: &mut f32, ) { if fixed.contains(&id) { return; } let translated = rects.get(&id).map(|rect| { let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0)); Rect { x: rect.x + tx, y: rect.y + ty, width: rect.width, height: rect.height, } }); if let Some(overflow) = translated { let visible = if let Some(clip) = inherited_clip { overflow.intersect(&clip) } else { Some(overflow) }; if let Some(overflow) = visible { *right = right.max(overflow.x + overflow.width); *bottom = bottom.max(overflow.y + overflow.height); } } // Overflow propagated from html/body establishes the viewport clip for // painting, but it does not truncate the root scrolling area's CSSOM // overflow dimensions. Ordinary descendant overflow clips still bound // their subtree's contribution. let is_viewport_overflow_source = tree.get_node(id).is_some_and(|node| { node.as_element() .is_some_and(|element| matches!(element.local.as_ref(), "html" | "body")) }); let child_clip = match (styles.get(&id), rects.get(&id)) { (Some(style), Some(rect)) if style.overflow_hidden && !is_viewport_overflow_source => { let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0)); let own = overflow_clip_rect(rect, style, tx, ty); Some(match inherited_clip { Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()), None => own, }) } _ => inherited_clip, }; for child in tree.children(id) { accumulate_scrolling_overflow( tree, child, child_clip, fixed, rects, styles, translates, right, bottom, ); } } #[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, } fn sync_resolved_percentage_padding( taffy_tree: &TaffyTree, taffy_root: taffy::NodeId, initial_containing_block_width: f32, id_map: &HashMap, generated: &[GeneratedBoxBuild], styles: &mut HashMap, ) { let has_percentage_padding = styles.values().any(|style| { style.padding_percent.iter().any(Option::is_some) || style .before_pseudo .as_deref() .is_some_and(|pseudo| pseudo.padding_percent.iter().any(Option::is_some)) || style .after_pseudo .as_deref() .is_some_and(|pseudo| pseudo.padding_percent.iter().any(Option::is_some)) }); if !has_percentage_padding { return; } fn sync(style: &mut crate::LayoutStyle, containing_block_width: f32) { if let Some(percent) = style.padding_percent[0] { style.padding.top = percent * containing_block_width; } if let Some(percent) = style.padding_percent[1] { style.padding.right = percent * containing_block_width; } if let Some(percent) = style.padding_percent[2] { style.padding.bottom = percent * containing_block_width; } if let Some(percent) = style.padding_percent[3] { style.padding.left = percent * containing_block_width; } } fn visit( taffy_tree: &TaffyTree, node: taffy::NodeId, containing_block_width: f32, id_map: &HashMap, generated: &HashMap, styles: &mut HashMap, ) { let Ok(layout) = taffy_tree.layout(node) else { return; }; if let Some(dom_id) = id_map.get(&node) { if let Some(style) = styles.get_mut(dom_id) { sync(style, containing_block_width); } } else if let Some((host, kind)) = generated.get(&node) { if let Some(host_style) = styles.get_mut(host) { let pseudo = match kind { GeneratedBoxKind::Before => host_style.before_pseudo.as_deref_mut(), GeneratedBoxKind::After => host_style.after_pseudo.as_deref_mut(), }; if let Some(pseudo) = pseudo { sync(pseudo, containing_block_width); } } } let child_containing_block_width = layout.content_box_width().max(0.0); if let Ok(children) = taffy_tree.children(node) { for child in children { visit( taffy_tree, child, child_containing_block_width, id_map, generated, styles, ); } } } let generated = generated .iter() .map(|generated| (generated.node, (generated.host, generated.kind))) .collect(); visit( taffy_tree, taffy_root, initial_containing_block_width, id_map, &generated, styles, ); } fn sync_positioned_pseudo_percentage_padding( rects: &HashMap, styles: &mut HashMap, ) { let has_positioned_percentage_padding = styles.values().any(|style| { [style.before_pseudo.as_deref(), style.after_pseudo.as_deref()] .into_iter() .flatten() .any(|pseudo| { pseudo.position == Some(taffy::Position::Absolute) && pseudo.padding_percent.iter().any(Option::is_some) }) }); if !has_positioned_percentage_padding { return; } fn sync(pseudo: &mut crate::LayoutStyle, containing_block_width: f32) { if pseudo.position != Some(taffy::Position::Absolute) { return; } for (index, percent) in pseudo.padding_percent.into_iter().enumerate() { let Some(percent) = percent else { continue }; let value = percent * containing_block_width; match index { 0 => pseudo.padding.top = value, 1 => pseudo.padding.right = value, 2 => pseudo.padding.bottom = value, _ => pseudo.padding.left = value, } } } for (host, style) in styles { let Some(rect) = rects.get(host) else { continue }; let containing_block_width = (rect.width - style.border.left - style.border.right).max(0.0); if let Some(pseudo) = style.before_pseudo.as_deref_mut() { sync(pseudo, containing_block_width); } if let Some(pseudo) = style.after_pseudo.as_deref_mut() { sync(pseudo, containing_block_width); } } } /// 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)); } // Overflow propagated from html/body establishes the root scrolling // viewport. It is anchored to the capture surface, not to document // coordinates, and the pixmap already supplies that viewport clip. // Materializing it here as an ordinary descendant clip would make root // scrolling translate the viewport itself offscreen. let is_viewport_overflow_source = tree.get_node(id).is_some_and(|node| { node.as_element().is_some_and(|element| { if element.local.as_ref() == "html" { return true; } if element.local.as_ref() != "body" { return false; } // Body overflow propagates to the viewport only while the root's // own overflow is visible. Once html establishes overflow, body // remains an ordinary clipping box. node.parent .and_then(|parent| tree.get_node(parent).map(|parent_node| (parent, parent_node))) .is_some_and(|(parent, parent_node)| { parent_node .as_element() .is_some_and(|name| name.local.as_ref() == "html") && !styles .get(&parent) .is_some_and(|style| style.overflow_hidden) }) }) }); let next = match (styles.get(&id), rects.get(&id)) { (Some(style), Some(rect)) if style.overflow_hidden && !is_viewport_overflow_source => { 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); style.aspect_ratio_is_mapped = true; } } } // 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, }; style.aspect_ratio_is_mapped = style.aspect_ratio.is_some(); } /// 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>, container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>, 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(); let effective_props = if let Some(evaluator) = container_evaluator.as_deref_mut() { sheet.apply_with_container_queries( tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style, parent_props, node.get_attribute("style"), evaluator, ) } else { sheet.apply( tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style, parent_props, node.get_attribute("style"), ) }; if let Some(m) = effective_props { this_props = std::rc::Rc::new(m); } let (before_pseudo, after_pseudo) = if let Some(evaluator) = container_evaluator.as_deref_mut() { sheet.pseudo_styles_with_container_queries( tree, matcher, id, &this_props, &style, evaluator, ) } else { 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, container_evaluator, 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) } const CONTAINER_LAYOUT_SAFETY_LIMIT: usize = 512; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ContainerLayoutTermination { NoQueries, GeometryStable, SignatureStable, OscillationFallback, PassCapFallback, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ContainerLayoutTelemetry { passes: usize, termination: ContainerLayoutTermination, query: crate::css::ContainerQueryStats, } fn container_iteration_termination( geometry_stable: bool, signature: &T, previous_signature: Option<&T>, ) -> Option { if geometry_stable { Some(ContainerLayoutTermination::GeometryStable) } else if previous_signature == Some(signature) { Some(ContainerLayoutTermination::SignatureStable) } else { None } } pub(crate) fn layout_dom_with_web_fonts( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[crate::inline::WebFont], ) -> DomLayout { layout_dom_with_web_fonts_measured(tree, viewport, intrinsic, fonts).0 } fn layout_dom_with_web_fonts_measured( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[crate::inline::WebFont], ) -> (DomLayout, ContainerLayoutTelemetry) { layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None) } fn layout_dom_with_web_fonts_pass_limit( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[crate::inline::WebFont], pass_limit: Option, ) -> (DomLayout, ContainerLayoutTelemetry) { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); // Collect the text of every
"#, ); let laid = layout_dom(&tree, (1000.0, 300.0)); let rect = |name| laid.rects[&tree.get_element_by_id(name).unwrap()]; let host = rect("host"); let grid = rect("grid"); let card_a = rect("card-a"); let card_b = rect("card-b"); let flex = rect("flex"); assert!((host.width - 900.0).abs() < 0.01, "{host:?}"); assert!((grid.width - 900.0).abs() < 0.01, "{grid:?}"); assert!( (card_a.width - 440.0).abs() < 0.01 && (card_b.width - 440.0).abs() < 0.01 && (card_b.x - card_a.x - 460.0).abs() < 0.01, "grid tracks escaped their containing block: {card_a:?} {card_b:?}" ); assert!((flex.width - 900.0).abs() < 0.01, "{flex:?}"); } #[test] fn sticky_normal_flow_excludes_own_pixel_and_percentage_translates() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (400.0, 240.0)); let sticky = laid.root_sticky_layout(&tree, (400.0, 240.0)); for name in ["pixel", "percent"] { let id = tree.get_element_by_id(name).unwrap(); let rect = laid.rects[&id]; let frame = sticky.frames.iter().find(|frame| frame.id == id).unwrap(); assert!( (frame.normal.x - rect.x).abs() < 0.01 && (frame.normal.y - rect.y).abs() < 0.01, "{name} own transform must not alter its sticky normal position: \ rect={rect:?} frame={frame:?}" ); } } #[test] fn sticky_normal_flow_retains_transformed_ancestor_coordinates() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (400.0, 240.0)); let id = tree.get_element_by_id("sticky").unwrap(); let rect = laid.rects[&id]; let sticky = laid.root_sticky_layout(&tree, (400.0, 240.0)); let frame = sticky.frames.iter().find(|frame| frame.id == id).unwrap(); assert!( (frame.normal.x - (rect.x + 25.0)).abs() < 0.01 && (frame.normal.y - (rect.y + 30.0)).abs() < 0.01, "ancestor transform must remain in sticky constraint coordinates: \ rect={rect:?} frame={frame:?}" ); } #[test] fn container_query_uses_final_content_box_geometry() { let tree = parse_html( r#"
"#, ); let (laid, telemetry) = layout_dom_with_web_fonts_measured(&tree, (1280.0, 720.0), &HashMap::new(), &[]); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); assert!((laid.rects[&container].width - 500.0).abs() < 0.1); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(44.0)); assert_eq!(telemetry.passes, 2); assert_eq!( telemetry.termination, ContainerLayoutTermination::GeometryStable ); } #[test] fn container_query_named_and_unnamed_lookup_choose_different_ancestors() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(11.0)); assert_eq!(laid.styles[&target].height, crate::Dimension::Px(1.0)); } #[test] fn nested_container_queries_select_independent_ancestors() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(19.0)); } #[test] fn container_query_pseudo_can_select_its_originating_element() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let container = tree.get_element_by_id("container").unwrap(); assert_eq!( laid.styles[&container] .before_pseudo .as_ref() .and_then(|pseudo| pseudo.before_content.as_deref()), Some("active") ); } #[test] fn container_type_zeroes_shrink_to_fit_inline_intrinsic_widths() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 300.0)); for name in ["inline-block", "inline-flex", "inline-grid"] { let id = tree.get_element_by_id(name).unwrap(); let rect = laid.rects[&id]; assert!( rect.width.abs() < 0.01, "{name} descendants leaked into contained intrinsic width: {rect:?}" ); assert!( rect.height >= 29.9, "inline-size containment must retain content-driven block size: {rect:?}" ); } } #[test] fn inline_outer_boxes_shrink_wrap_in_only_child_and_text_flows() { let tree = parse_html( r#"
A
B
A
B
A
B
"#, ); let laid = layout_dom(&tree, (700.0, 300.0)); for name in ["ib", "if", "ig", "ibt", "ift", "igt"] { let rect = laid.rects[&tree.get_element_by_id(name).unwrap()]; assert!( (rect.width - 400.0).abs() < 0.01, "{name} did not shrink-wrap its 400px child: {rect:?}" ); } } #[test] fn inline_flex_and_grid_preserve_percentage_and_box_edge_geometry() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (800.0, 300.0)); let rect = |name| laid.rects[&tree.get_element_by_id(name).unwrap()]; for name in ["flex-percent", "grid-percent"] { let item = rect(name); assert!( (item.width - 350.0).abs() < 0.01 && (item.height - 20.0).abs() < 0.01, "{name}: {item:?}" ); } for name in ["flex-edges", "grid-edges"] { let item = rect(name); assert!( (item.x - 7.0).abs() < 0.01 && (item.width - 150.0).abs() < 0.01 && (item.height - 50.0).abs() < 0.01, "{name}: {item:?}" ); } } #[test] fn inline_flex_and_grid_resolve_percentage_min_max_against_containing_block() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (800.0, 300.0)); for name in ["flex-max", "grid-max", "flex-min", "grid-min"] { let item = laid.rects[&tree.get_element_by_id(name).unwrap()]; assert!( (item.width - 350.0).abs() < 0.01, "{name} resolved its percentage constraint against the wrong box: {item:?}" ); } } #[test] fn inline_atomic_vertical_align_controls_cross_axis_placement() { let tree = parse_html( r#"
x
x
"#, ); let laid = layout_dom(&tree, (800.0, 200.0)); let rect = |name| laid.rects[&tree.get_element_by_id(name).unwrap()]; let bottom_short = rect("bottom-short"); let bottom_tall = rect("bottom-tall"); assert!( (bottom_short.y + bottom_short.height - bottom_tall.y - bottom_tall.height).abs() < 0.01, "bottom-aligned atoms did not share a line bottom: {bottom_short:?} {bottom_tall:?}" ); let middle_short = rect("middle-short"); let middle_tall = rect("middle-tall"); assert!( (middle_short.y + middle_short.height / 2.0 - middle_tall.y - middle_tall.height / 2.0) .abs() < 0.01, "middle-aligned atoms did not share a line center: {middle_short:?} {middle_tall:?}" ); } #[test] fn blockified_inline_flex_and_grid_items_retain_inner_layout() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (700.0, 300.0)); let rect = |name| laid.rects[&tree.get_element_by_id(name).unwrap()]; for (container, first, second) in [("if", "ifa", "ifb"), ("ig", "iga", "igb")] { let container = rect(container); let first = rect(first); let second = rect(second); assert!((container.width - 200.0).abs() < 0.01, "{container:?}"); assert!( (first.y - second.y).abs() < 0.01 && (second.x - first.x - 100.0).abs() < 0.01, "inner layout was destroyed: first={first:?} second={second:?}" ); } } #[test] fn root_and_body_inline_outer_geometry_matches_blockification_rules() { for display in ["inline-block", "inline-flex", "inline-grid"] { let tree = parse_html(&format!( r#"
"# )); let laid = layout_dom(&tree, (800.0, 300.0)); let body = tree .descendants(tree.document()) .into_iter() .find(|id| { tree.get_node(*id) .is_some_and(|node| { node.as_element() .is_some_and(|element| element.local.as_ref() == "body") }) }) .unwrap(); assert!( (laid.rects[&body].width - 400.0).abs() < 0.01, "body {display}: {:?}", laid.rects[&body] ); let tree = parse_html(&format!( r#"
"# )); let laid = layout_dom(&tree, (800.0, 300.0)); let root = tree .descendants(tree.document()) .into_iter() .find(|id| { tree.get_node(*id) .is_some_and(|node| { node.as_element() .is_some_and(|element| element.local.as_ref() == "html") }) }) .unwrap(); assert!( (laid.rects[&root].width - 800.0).abs() < 0.01, "root {display}: {:?}", laid.rects[&root] ); } } #[test] fn viewport_overflow_clip_does_not_truncate_root_scrolling_overflow() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (900.0, 1000.0)); let main = laid.rects[&tree.get_element_by_id("main").unwrap()]; let content = laid.scrolling_content_size(&tree, (900.0, 1000.0)); assert!((main.height - 5048.0).abs() < 0.01, "{main:?}"); assert_eq!(content, (900.0, 5048.0)); } #[test] fn descendant_overflow_clip_still_bounds_root_scrolling_overflow() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (900.0, 1000.0)); assert_eq!( laid.scrolling_content_size(&tree, (900.0, 1000.0)), (900.0, 1000.0) ); } #[test] fn size_container_auto_block_size_ignores_descendants() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 300.0)); let inline_only = laid.rects[&tree.get_element_by_id("inline-only").unwrap()]; let both = laid.rects[&tree.get_element_by_id("both").unwrap()]; assert!((inline_only.height - 80.0).abs() < 0.01, "{inline_only:?}"); assert!( both.height.abs() < 0.01, "size containment must use the zero contain-intrinsic block size: {both:?}" ); } #[test] fn container_type_establishes_a_float_containing_formatting_context() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 200.0)); let container = laid.rects[&tree.get_element_by_id("container").unwrap()]; assert!( (container.height - 40.0).abs() < 0.01, "query container must contain its descendant float: {container:?}" ); } #[test] fn ineligible_nearest_inline_container_does_not_fall_through() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 200.0)); let target = tree.get_element_by_id("target").unwrap(); assert_eq!( laid.styles[&target].width, crate::Dimension::Px(1.0), "the unavailable nearest query axis must be unknown, not select the outer container" ); } #[test] fn ineligible_nearest_display_contents_container_does_not_fall_through() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 200.0)); let target = tree.get_element_by_id("target").unwrap(); assert_eq!( laid.styles[&target].width, crate::Dimension::Px(1.0), "a named display:contents container must remain the nearest match with unavailable axes" ); } #[test] fn ineligible_nearest_table_container_does_not_fall_through() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 200.0)); let inner = tree.get_element_by_id("inner").unwrap(); let target = tree.get_element_by_id("target").unwrap(); assert!( laid.styles[&inner].is_table_box, "the internal block approximation lost authored table provenance" ); assert_eq!( laid.styles[&target].width, crate::Dimension::Px(1.0), "an unavailable nearest table query axis must not fall through to the outer container" ); assert!( (laid.rects[&inner].height - 80.0).abs() < 0.01, "container-type must not apply size containment to an authored table box: {:?}", laid.rects[&inner] ); } #[test] fn container_type_does_not_size_contain_internal_table_cell() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (600.0, 200.0)); let cell = tree.get_element_by_id("cell").unwrap(); assert!( laid.styles[&cell].internal_flex_container, "the test must exercise the internal table-cell representation" ); assert!( laid.rects[&cell].height >= 79.9, "container-type must not zero an internal table cell: {:?}", laid.rects[&cell] ); } #[test] fn formerly_oscillating_inline_container_settles_consistently() { let tree = parse_html( r#"
"#, ); let (laid, telemetry) = layout_dom_with_web_fonts_measured(&tree, (600.0, 200.0), &HashMap::new(), &[]); let container = laid.rects[&tree.get_element_by_id("container").unwrap()]; let child = tree.get_element_by_id("child").unwrap(); assert!(container.width.abs() < 0.01, "{container:?}"); assert_eq!(laid.styles[&child].width, crate::Dimension::Px(200.0)); assert!(matches!( telemetry.termination, ContainerLayoutTermination::GeometryStable | ContainerLayoutTermination::SignatureStable )); } #[test] fn pass_cap_uses_visible_conservative_fallback_without_panicking() { let tree = parse_html( r#"
"#, ); let (laid, telemetry) = layout_dom_with_web_fonts_pass_limit( &tree, (600.0, 200.0), &HashMap::new(), &[], Some(2), ); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(1.0)); assert_eq!( telemetry.termination, ContainerLayoutTermination::PassCapFallback ); assert_eq!(telemetry.passes, 3); } #[test] fn ten_level_container_activation_propagates_without_fixed_pass_truncation() { let mut css = String::from( "#c0{container:c0/inline-size;width:100px}#target{width:1px}", ); for level in 0..10 { css.push_str(&format!( "@container c{level} (min-width:100px){{#c{}{{container:c{}/inline-size;width:100px}}}}", level + 1, level + 1 )); } css.push_str( "@container c10 (min-width:100px){#target{width:77px}}", ); let mut body = String::new(); for level in 0..=10 { body.push_str(&format!("
")); } body.push_str("
"); for _ in 0..=10 { body.push_str("
"); } let tree = parse_html(&format!("{body}")); let (laid, telemetry) = layout_dom_with_web_fonts_measured(&tree, (800.0, 600.0), &HashMap::new(), &[]); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(77.0)); assert!( telemetry.passes > 8, "deep propagation unexpectedly truncated: {telemetry:?}" ); assert!(!matches!( telemetry.termination, ContainerLayoutTermination::PassCapFallback | ContainerLayoutTermination::OscillationFallback )); } #[test] fn layout_without_container_queries_keeps_one_pass_fast_path() { let tree = parse_html( r#"
"#, ); let (_, telemetry) = layout_dom_with_web_fonts_measured(&tree, (800.0, 600.0), &HashMap::new(), &[]); assert_eq!( telemetry, ContainerLayoutTelemetry { passes: 1, termination: ContainerLayoutTermination::NoQueries, query: crate::css::ContainerQueryStats::default(), } ); } #[test] fn container_convergence_classifies_self_consistent_stability() { assert_eq!( container_iteration_termination(true, &3, Some(&2)), Some(ContainerLayoutTermination::GeometryStable) ); assert_eq!( container_iteration_termination(false, &3, Some(&3)), Some(ContainerLayoutTermination::SignatureStable) ); assert_eq!( container_iteration_termination(false, &3, Some(&2)), None ); assert_eq!(CONTAINER_LAYOUT_SAFETY_LIMIT, 512); } #[test] fn declarative_shadow_style_does_not_leak_into_document_cascade() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let button = tree.get_element_by_id("light-button").unwrap(); let rect = laid.rects[&button]; assert!((rect.width - 123.0).abs() < 0.1, "{rect:?}"); assert!((rect.height - 20.0).abs() < 0.1, "{rect:?}"); } #[test] fn multicol_balances_atomic_blocks_like_chromium() { // Chromium 140 geometry for this reduction is a 630x300 container, // 190px columns at x=0/220/440, and a 2/3/3 source-order partition. // Explicit heights keep this a layout-algorithm regression rather // than a font/raster comparison. let tree = parse_html( "\
\
\
\
\
\
\
\
\
\
", ); 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 static_positions_use_final_post_repair_geometry_without_phantom_overflow() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (800.0, 400.0)); let rect = |name| laid.rects[&tree.get_element_by_id(name).unwrap()]; let columns = rect("columns"); let holder = rect("holder"); let outer = rect("outer"); let wrapper = rect("wrapper"); let nested = rect("nested"); assert!((columns.height - 200.0).abs() < 0.01, "{columns:?}"); assert!((holder.y - 200.0).abs() < 0.01, "{holder:?}"); assert!( (outer.y - holder.y).abs() < 0.01, "outer static position was harvested before multicol repair: \ holder={holder:?} outer={outer:?}" ); assert!( (nested.y - wrapper.y).abs() < 0.01, "nested static-position candidate changed while reparenting its ancestor: \ wrapper={wrapper:?} nested={nested:?}" ); assert_eq!( laid.scrolling_content_size(&tree, (800.0, 400.0)), (800.0, 400.0), "a stale preliminary static position must not create scrolling overflow" ); } #[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 direct_flex_text_is_one_wrapping_anonymous_item() { // Chromium 140: the pseudo is one flex item and the direct text run // is one anonymous flex item with a 276px inline formatting context. // The sentence wraps to three 20px lines; treating every word as an // outer flex item instead produces one overflowing 20px line. let tree = parse_html( "\
This anonymous text item must wrap inside the remaining flex space instead of overflowing.
", ); let laid = layout_dom(&tree, (500.0, 200.0)); let quote = laid.rects[&tree.get_element_by_id("quote").unwrap()]; assert!((quote.width - 300.0).abs() < 0.1, "{quote:?}"); assert!((quote.height - 60.0).abs() < 0.1, "{quote:?}"); } #[test] fn auto_width_column_flex_text_uses_fit_content_width() { // Chromium 145: the outer row leaves 533px beside the fixed 203px // sibling and 32px gap. The auto-width quote is fit-content, so its // direct anonymous text item reflows to five 24px lines. Measuring it // only at max-content instead produces a 2097px-wide, 24px-tall line. let tree = parse_html( r#"
MDN closely follows W3C standards which helps me keep up with important topics. It's a complete package as it caters to everything; complex APIs, new browser functionalities, and best practices. MDN serves as a truly valuable resource and continues to assist me in my everyday development.
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; let row = rect("row"); let column = rect("column"); let quote = rect("quote"); let logo = rect("logo"); assert!((row.width - 768.0).abs() < 0.1, "{row:?}"); assert!((row.height - 128.0).abs() < 0.1, "{row:?}"); assert!((column.width - 533.0).abs() < 0.1, "{column:?}"); assert!((quote.width - 533.0).abs() < 0.1, "{quote:?}"); assert!((quote.height - 120.0).abs() < 0.1, "{quote:?}"); assert!((logo.x - 565.0).abs() < 0.1, "{logo:?}"); assert!((logo.width - 203.0).abs() < 0.1, "{logo:?}"); } #[test] fn column_flex_fit_content_preserves_unbreakable_min_content() { // Chromium 145 keeps the unbreakable quote at 2561.25px and lets the // constrained row overflow. The synthetic fit-content cap must not // turn an intrinsic min-content floor into overflow-wrap:anywhere. let word = "X".repeat(240); let tree = parse_html(&format!( r#"
{word}
"# )); let laid = layout_dom(&tree, (1280.0, 720.0)); let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; let column = rect("column"); let quote = rect("quote"); let logo = rect("logo"); assert!(quote.width > 2500.0, "{quote:?}"); assert!((column.width - quote.width).abs() < 0.1, "{column:?} {quote:?}"); assert!((quote.height - 24.0).abs() < 0.1, "{quote:?}"); assert!((logo.x - quote.width - 32.0).abs() < 0.1, "{logo:?}"); } #[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 laid = layout_dom(&tree, (1280.0, 720.0)); let cta = tree.get_element_by_id("cta").unwrap(); let items = &laid.run_ifc_items[&cta]; assert_eq!(items.len(), 1); assert_eq!(laid.text_engine.item_text(items[0]), "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 mut laid = layout_dom(&tree, (1280.0, 720.0)); let code = tree.get_element_by_id("code").unwrap(); let items = &laid.run_ifc_items[&code]; assert_eq!(items.len(), 1); let (_, height) = laid.text_engine.measure(items[0], Some(1280.0)); assert_eq!(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 positioned_pseudo_does_not_wrap_shrink_to_fit_host_text() { let tree = parse_html( r#" "#, ); let laid = layout_dom(&tree, (500.0, 300.0)); let host = tree.get_element_by_id("host").unwrap(); let rect = laid.rects[&host]; assert!( (120.0..=130.0).contains(&rect.width), "max-content width must include the shaped inter-word advance: {rect:?}" ); assert_eq!( rect.height, 40.0, "positioned generated content must not make the host label wrap" ); } #[test] fn variable_font_properties_inherit_and_reset_through_elements_and_pseudos() { let tree = parse_html( r#"
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); let get = |selector: &str| { let id = tree.query_selector(selector).unwrap().unwrap(); &laid.styles[&id] }; let parent = get("#parent"); let inherited = get("#inherited"); assert_eq!( parent.font_optical_sizing, Some(crate::FontOpticalSizing::None) ); assert_eq!(inherited.font_optical_sizing, parent.font_optical_sizing); assert_eq!( inherited.font_variation_settings, parent.font_variation_settings ); let before = parent.before_pseudo.as_ref().unwrap(); assert_eq!(before.font_optical_sizing, parent.font_optical_sizing); assert_eq!( before.font_variation_settings, parent.font_variation_settings ); let reset = get("#reset"); let reset_child = get("#reset-child"); assert_eq!( reset.font_optical_sizing, Some(crate::FontOpticalSizing::Auto) ); assert_eq!(reset.font_variation_settings, Some(Vec::new())); assert_eq!(reset_child.font_optical_sizing, reset.font_optical_sizing); assert_eq!( reset_child.font_variation_settings, reset.font_variation_settings ); let after = reset.after_pseudo.as_ref().unwrap(); assert_eq!(after.font_optical_sizing, reset.font_optical_sizing); assert_eq!( after.font_variation_settings.as_deref(), Some( [crate::FontVariationSetting { tag: *b"wght", value: 300.0, }] .as_slice() ) ); } #[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:?}" ); } }