Skip to main content

orinium_browser/engine/layouter/builder/
mod.rs

1//! Layout builder, which transforms a DOM tree into a UI layout.
2
3mod apply;
4mod background;
5mod color;
6mod css_resolve;
7mod layout_fix;
8#[cfg(test)]
9mod tests;
10
11pub use apply::{apply_declaration, blockify_out_of_flow_positioned};
12pub use layout_fix::{
13    constrain_auto_grid_track_items, correct_atomic_inline_spacing,
14    correct_atomic_inline_spacing_with_info, is_block_layout_child, is_collapsible_whitespace_info,
15    maximum_fixed_descendant_width, refresh_missing_text_layout_results,
16};
17
18#[allow(unused_imports)]
19pub use background::{
20    apply_background_shorthand_geometry, parse_background_position, parse_background_repeat,
21    parse_background_shorthand, parse_background_size, parse_gradient,
22};
23pub use color::resolve_css_color;
24pub use css_resolve::{
25    CalcValue, extract_font_families, one_or_two_values, parse_grid_line, parse_grid_line_end,
26    parse_grid_placement, parse_grid_template_areas, parse_grid_tracks, resolve_calc_value,
27    resolve_css_len, resolve_css_len_auto, resolve_font_size_px,
28};
29
30use crate::{perf_scope, profile_log};
31
32use crate::engine::bridge::text::{self, GlyphCluster};
33use crate::engine::css::{
34    matcher::{ElementChain, ElementInfo},
35    values::{CssValue, Unit},
36};
37use crate::engine::html::{HtmlNodeType, ScriptingMode};
38use crate::engine::layouter::css_resolver::{
39    DeclarationResolver, Properties, resolve_inline_value,
40};
41use crate::engine::layouter::dom_snapshot::{DomSnapshot, NodeId};
42use crate::engine::layouter::types::WhiteSpace;
43use crate::engine::renderer_model::Image;
44use crate::engine::tree::NodeRef;
45use crate::engine::ui::custom_node_bridge::CustomNodeBridge;
46use crate::engine::ui::registry::{ComponentRegistry, CustomNodeContext, DomWriteBack};
47
48use std::collections::{HashMap, HashSet};
49use std::sync::Arc;
50
51#[allow(unused_imports)]
52use ui_layout::{
53    AlignContent, AlignItems, AutoSizeBehavior, BoxSizing, Display, FlexDirection, FlexWrap,
54    GridPlacement, GridPlacementEnd, GridRepeat, GridTrack, InnerDisplay, ItemFragment,
55    JustifyContent, JustifyItems, LayoutChild, LayoutNode, Length, LengthOrAuto, OuterDisplay,
56    Position, Style,
57};
58
59use super::css_resolver::{
60    MediaEnvironment, ResolvedStyles, RuleSet, resolve_inline_style, set_inline_custom_property,
61};
62use super::text_layouter::TextFlowLayouter;
63#[allow(unused_imports)]
64use super::types::{
65    Background, BackgroundDimension, BackgroundOffset, BackgroundPosition, BackgroundPositionAxis,
66    BackgroundRepeat, BackgroundSize, BorderRadius, BorderStyle, Color, ColorScheme, ColorStop,
67    ContainerRole, ContainerStyle, CornerRadius, CssFloat, FontStyle, FontWeight, Gradient,
68    GradientKind, InfoNode, LineHeight, NodeKind, Overflow, RadialShape, RadialSizeKind, TextAlign,
69    TextDecoration, TextFlowStyle, TextStyle, TextTransform,
70};
71
72pub(crate) const DEFAULT_LINE_FACTOR: f32 = 1.2;
73
74const GRID_LINE_TO_END: GridPlacementEnd = GridPlacementEnd::Line(usize::MAX);
75
76pub(crate) fn element_info(html_node: &HtmlNodeType) -> Option<ElementInfo> {
77    let HtmlNodeType::Element {
78        tag_name,
79        attributes,
80        ..
81    } = html_node
82    else {
83        return None;
84    };
85    Some(ElementInfo {
86        tag_name: tag_name.clone(),
87        id: attributes
88            .iter()
89            .find(|attribute| attribute.name == "id")
90            .map(|attribute| attribute.value.clone()),
91        classes: attributes
92            .iter()
93            .find(|attribute| attribute.name == "class")
94            .map(|attribute| {
95                attribute
96                    .value
97                    .split_whitespace()
98                    .map(str::to_string)
99                    .collect()
100            })
101            .unwrap_or_default(),
102        attributes: attributes
103            .iter()
104            .map(|attribute| (attribute.name.clone(), attribute.value.clone()))
105            .collect(),
106        element_index: 1,
107        element_count: 1,
108        type_index: 1,
109        type_count: 1,
110        previous_siblings: ElementChain::default(),
111    })
112}
113
114pub(crate) fn element_sibling_infos(
115    snapshot: &DomSnapshot,
116    children: &[NodeId],
117) -> Vec<Option<ElementInfo>> {
118    let mut type_counts = HashMap::<String, usize>::new();
119    for &child in children {
120        if let Some(tag_name) = snapshot.node(child).kind.tag_name() {
121            *type_counts.entry(tag_name.to_string()).or_default() += 1;
122        }
123    }
124    let element_count = type_counts.values().sum();
125
126    let mut seen_types = HashMap::<String, usize>::new();
127    let mut previous_siblings = ElementChain::default();
128    let mut element_index = 0;
129    children
130        .iter()
131        .map(|&child| {
132            let mut info = match element_info(&snapshot.node(child).kind) {
133                Some(info) => info,
134                None => return None,
135            };
136            element_index += 1;
137            let seen = seen_types.entry(info.tag_name.clone()).or_default();
138            *seen += 1;
139            info.element_index = element_index;
140            info.element_count = element_count;
141            info.type_index = *seen;
142            info.type_count = type_counts[&info.tag_name];
143            info.previous_siblings = previous_siblings.clone();
144
145            let mut sibling = info.clone();
146            sibling.previous_siblings = ElementChain::default();
147            previous_siblings = previous_siblings.prepend(Some(sibling));
148            Some(info)
149        })
150        .collect()
151}
152
153/// Inherited values from parent, passed down through the tree.
154///
155/// `text_style` carries all inherited text/line-height values.
156/// `color_scheme` carries the element's used color scheme (resolved from the
157/// `color-scheme` property and the system preference).
158/// Add new fields here when additional deferred-resolution properties arise.
159#[derive(Clone, Default)]
160pub struct InheritedCss {
161    /// Inherited CSS custom properties, shared copy-on-write: descendants that
162    /// do not introduce new `--var` values reuse the parent's map via `Arc`
163    /// instead of deep-cloning it per element.
164    pub custom_props: Arc<Properties>,
165    pub text_style: TextStyle,
166    pub text_flow_style: TextFlowStyle,
167    pub color_scheme: ColorScheme,
168}
169
170/// Convert a resolved `Length` to an absolute pixel value for `LineHeight::Px`.
171pub(super) fn length_to_px(len: &Length, font_size: f32) -> f32 {
172    match len {
173        Length::Px(v) => *v,
174        Length::Percent(v) => v * font_size / 100.0,
175        Length::Add(a, b) => length_to_px(a, font_size) + length_to_px(b, font_size),
176        Length::Sub(a, b) => length_to_px(a, font_size) - length_to_px(b, font_size),
177        Length::Mul(a, f) => length_to_px(a, font_size) * f,
178        Length::Div(a, f) => length_to_px(a, font_size) / f,
179        _ => font_size * DEFAULT_LINE_FACTOR,
180    }
181}
182
183struct StackFrame {
184    dom: NodeId,
185    chain: ElementChain,
186    child: Arc<InheritedCss>,
187    kind: Option<NodeKind>,
188    style: Option<Style>,
189    parent_style: Style,
190    parent_container_style: ContainerStyle,
191    child_slots: Vec<ChildSlot>,
192    element_children: Vec<NodeId>,
193}
194
195enum ChildSlot {
196    Inline(LayoutChild, Box<InfoNode>),
197    Element(usize),
198}
199
200pub fn build_layout_and_info(
201    dom: &NodeRef<HtmlNodeType>,
202    resolved_styles: &ResolvedStyles,
203    measurer: Arc<dyn text::TextMeasurer>,
204    parent: InheritedCss,
205    chain: ElementChain,
206    system_color_scheme: ColorScheme,
207    scripting_mode: ScriptingMode,
208) -> (LayoutNode, InfoNode) {
209    build_layout_and_info_with_images(
210        dom,
211        resolved_styles,
212        measurer,
213        parent,
214        chain,
215        system_color_scheme,
216        scripting_mode,
217        &HashMap::new(),
218    )
219}
220
221/// Builds layout and render trees with decoded images keyed by their `src` value.
222#[allow(clippy::too_many_arguments)]
223pub fn build_layout_and_info_with_images(
224    dom: &NodeRef<HtmlNodeType>,
225    resolved_styles: &ResolvedStyles,
226    measurer: Arc<dyn text::TextMeasurer>,
227    parent: InheritedCss,
228    chain: ElementChain,
229    system_color_scheme: ColorScheme,
230    scripting_mode: ScriptingMode,
231    images: &HashMap<String, Image>,
232) -> (LayoutNode, InfoNode) {
233    let (snapshot, _dom_refs) = DomSnapshot::from_tree(dom);
234    let media_environment = MediaEnvironment::new((0.0, 0.0), system_color_scheme);
235    let rule_set = RuleSet::from_declarations(resolved_styles, &media_environment);
236    build_layout_and_info_from_snapshot(
237        &snapshot,
238        snapshot.roots()[0],
239        &rule_set,
240        measurer,
241        parent,
242        chain,
243        system_color_scheme,
244        scripting_mode,
245        images,
246        &HashMap::new(),
247        None,
248    )
249}
250
251/// Builds layout and render trees from a [`DomSnapshot`].
252///
253/// `write_back_sender` (when set) is cloned per text input so value changes
254/// are reported as `(node id, value)` on the channel instead of mutating the
255/// DOM directly, which allows this function to run off the UI thread.
256///
257/// `system_color_scheme` seeds the root element's used color scheme and is
258/// used to resolve `color-scheme: light dark` and `light-dark()` values.
259#[allow(clippy::too_many_arguments)]
260pub fn build_layout_and_info_from_snapshot(
261    snapshot: &DomSnapshot,
262    root: NodeId,
263    rule_set: &RuleSet,
264    measurer: Arc<dyn text::TextMeasurer>,
265    parent: InheritedCss,
266    mut chain: ElementChain,
267    system_color_scheme: ColorScheme,
268    scripting_mode: ScriptingMode,
269    images: &HashMap<String, Image>,
270    audio: &HashMap<String, Arc<[u8]>>,
271    write_back_sender: Option<DomWriteBack>,
272) -> (LayoutNode, InfoNode) {
273    perf_scope!(total);
274
275    #[cfg(any(feature = "profile", debug_assertions))]
276    let mut css_match_time = std::time::Duration::ZERO;
277    #[cfg(any(feature = "profile", debug_assertions))]
278    let mut apply_decl_time = std::time::Duration::ZERO;
279    #[cfg(any(feature = "profile", debug_assertions))]
280    let mut custom_node_time = std::time::Duration::ZERO;
281    #[cfg(any(feature = "profile", debug_assertions))]
282    let mut text_layout_time = std::time::Duration::ZERO;
283    #[cfg(any(feature = "profile", debug_assertions))]
284    let mut exit_phase_time = std::time::Duration::ZERO;
285    #[cfg(any(feature = "profile", debug_assertions))]
286    let mut sibling_info_time = std::time::Duration::ZERO;
287    #[cfg(any(feature = "profile", debug_assertions))]
288    let mut whitespace_keep_time = std::time::Duration::ZERO;
289    #[cfg(any(feature = "profile", debug_assertions))]
290    let mut enter_prep_time = std::time::Duration::ZERO;
291    #[cfg(any(feature = "profile", debug_assertions))]
292    let mut child_slot_build_time = std::time::Duration::ZERO;
293    #[cfg(any(feature = "profile", debug_assertions))]
294    let mut leaf_assemble_time = std::time::Duration::ZERO;
295    #[cfg(any(feature = "profile", debug_assertions))]
296    let mut push_children_time = std::time::Duration::ZERO;
297    #[cfg(any(feature = "profile", debug_assertions))]
298    let mut exit_setup_time = std::time::Duration::ZERO;
299    #[cfg(any(feature = "profile", debug_assertions))]
300    let mut exit_build_time = std::time::Duration::ZERO;
301    #[cfg(any(feature = "profile", debug_assertions))]
302    let mut node_count = 0u64;
303
304    #[cfg(any(feature = "profile", debug_assertions))]
305    let mut cand_stats = CandidateMetrics::default();
306
307    let registry = ComponentRegistry::new();
308    /*
309     * Build the initial element chain for the root node.
310     */
311    if let Some(info) = element_info(&snapshot.node(root).kind) {
312        chain = chain.prepend(Some(info));
313    }
314
315    // ── Explicit post-order stack (index-based to avoid borrow conflicts) ──
316
317    let mut stack: Vec<StackFrame> = Vec::new();
318    stack.push(StackFrame {
319        dom: root,
320        chain,
321        child: Arc::new(parent),
322        kind: None,
323        style: None,
324        parent_style: Style::default(),
325        parent_container_style: ContainerStyle::default(),
326        child_slots: Vec::new(),
327        element_children: Vec::new(),
328    });
329
330    let mut results: HashMap<NodeId, (LayoutNode, InfoNode)> = HashMap::new();
331
332    // We use an index instead of .last_mut() so that push/pop don't conflict
333    // with the mutable reference to the current frame.
334    while let Some(top_idx) = {
335        if stack.is_empty() {
336            None
337        } else {
338            Some(stack.len() - 1)
339        }
340    } {
341        // Phase check must happen BEFORE borrowing stack[top_idx] mutably.
342        let is_entered = stack[top_idx].kind.is_some();
343
344        if !is_entered {
345            // ── ENTER phase ──────────────────────────────────────────────
346            // Read frame state we need before taking any mutable action.
347            let chain_for_css = stack[top_idx].chain.clone();
348            let child_css = Arc::clone(&stack[top_idx].child);
349
350            let html_node = &snapshot.node(stack[top_idx].dom).kind;
351            let mut text_style = child_css.text_style.clone();
352            let mut text_flow_style = child_css.text_flow_style;
353            let mut container_style = ContainerStyle::default();
354            let mut style = Style::default();
355            let mut overflow = Overflow::default();
356            let parent_style = stack[top_idx].parent_style.clone();
357            let parent_container_style = stack[top_idx].parent_container_style.clone();
358            let parent_text_style = text_style.clone();
359            let parent_text_flow_style = text_flow_style;
360
361            // Inherit container visibility
362            container_style.visibility = parent_container_style.visibility;
363
364            // Collect CSS candidates.
365            perf_scope!(css_match);
366            let (candidates, custom_property_candidates) =
367                if let HtmlNodeType::Element { .. } = html_node {
368                    Some(collect_candidates(
369                        rule_set,
370                        &chain_for_css,
371                        #[cfg(any(feature = "profile", debug_assertions))]
372                        &mut cand_stats,
373                    ))
374                } else {
375                    None
376                }
377                .unzip();
378            #[cfg(any(feature = "profile", debug_assertions))]
379            {
380                css_match_time += css_match.elapsed();
381            }
382
383            perf_scope!(enter_prep);
384            // Inherit custom properties by sharing the parent's map unless this
385            // element introduces new `--var` values (copy-on-write applies
386            // cascade-discovered custom properties lazily).
387            let mut custom_properties = Arc::clone(&child_css.custom_props);
388            if let Some(own) = custom_property_candidates
389                && !own.is_empty()
390            {
391                Arc::make_mut(&mut custom_properties).extend(own);
392            }
393
394            // Resolve the used color scheme for this element. `light-dark()`
395            // and system colors resolve against it, and it is inherited by
396            // descendants that do not set `color-scheme` themselves.
397            let used_color_scheme = {
398                let declaration = candidates
399                    .as_ref()
400                    .and_then(|c| c.get("color-scheme"))
401                    .and_then(|d| {
402                        DeclarationResolver::resolve_var(
403                            &d.value,
404                            &custom_properties,
405                            &mut HashSet::new(),
406                        )
407                    });
408                resolve_used_color_scheme(
409                    declaration.as_ref(),
410                    child_css.color_scheme,
411                    system_color_scheme,
412                )
413            };
414            #[cfg(any(feature = "profile", debug_assertions))]
415            {
416                enter_prep_time += enter_prep.elapsed();
417            }
418
419            // Apply CSS declarations.
420            perf_scope!(apply_decl);
421            if let Some(candidates) = &candidates {
422                // The candidates map dedupes per property name (cascade winner),
423                // but a shorthand and its longhand (e.g. `padding` and
424                // `padding-top`) are distinct keys. Iterating a HashMap applies
425                // them in random order, so re-sort by source order to make the
426                // cascade deterministic.
427                let mut candidates: Vec<_> = candidates.values().collect();
428                candidates.sort_by_key(|declaration| declaration.order);
429
430                for declaration in candidates {
431                    if declaration.name.starts_with("--") {
432                        continue;
433                    }
434                    let Some(value) = DeclarationResolver::resolve_var(
435                        &declaration.value,
436                        &custom_properties,
437                        &mut HashSet::new(),
438                    ) else {
439                        continue;
440                    };
441                    apply_declaration(
442                        &declaration.name,
443                        &value,
444                        &mut style,
445                        &mut container_style,
446                        &mut text_style,
447                        &mut text_flow_style,
448                        &parent_style,
449                        &parent_container_style,
450                        &parent_text_style,
451                        &parent_text_flow_style,
452                        &mut overflow,
453                        used_color_scheme,
454                    );
455                }
456            }
457
458            // Apply the element's inline `style` attribute. Inline styles are
459            // author-origin declarations with the highest specificity, so they
460            // override stylesheet rules — unless the stylesheet rule was
461            // `!important`, which still wins over a non-`!important` inline
462            // declaration.
463            if let Some(style_attr) = html_node.get_attr("style") {
464                let inline_declarations = resolve_inline_style(style_attr);
465                for (name, value, important) in &inline_declarations {
466                    if name.starts_with("--") {
467                        let stylesheet_important = candidates
468                            .as_ref()
469                            .and_then(|c| c.get(name))
470                            .is_some_and(|declaration| declaration.important);
471                        if *important || !stylesheet_important {
472                            set_inline_custom_property(
473                                Arc::make_mut(&mut custom_properties),
474                                name.clone(),
475                                value.clone(),
476                                *important,
477                            );
478                        }
479                    }
480                }
481                for (name, value, important) in inline_declarations {
482                    if name.starts_with("--") {
483                        continue;
484                    }
485                    let stylesheet_important = candidates
486                        .as_ref()
487                        .and_then(|c| c.get(&name))
488                        .is_some_and(|declaration| declaration.important);
489                    if !important && stylesheet_important {
490                        continue;
491                    }
492                    let Some(value) = DeclarationResolver::resolve_var(
493                        &value,
494                        &custom_properties,
495                        &mut HashSet::new(),
496                    ) else {
497                        continue;
498                    };
499                    apply_declaration(
500                        &name,
501                        &value,
502                        &mut style,
503                        &mut container_style,
504                        &mut text_style,
505                        &mut text_flow_style,
506                        &parent_style,
507                        &parent_container_style,
508                        &parent_text_style,
509                        &parent_text_flow_style,
510                        &mut overflow,
511                        used_color_scheme,
512                    );
513                }
514            }
515
516            // Apply attribute sizing
517            for attr in ["width", "height"] {
518                if let Some(value) = html_node.get_attr(attr)
519                    && let Some(mut value) = resolve_inline_value(value)
520                {
521                    if let CssValue::Number(v) = value {
522                        value = CssValue::Length(v, Unit::Px);
523                    }
524                    apply_declaration(
525                        attr,
526                        &value,
527                        &mut style,
528                        &mut container_style,
529                        &mut text_style,
530                        &mut text_flow_style,
531                        &parent_style,
532                        &parent_container_style,
533                        &parent_text_style,
534                        &parent_text_flow_style,
535                        &mut overflow,
536                        used_color_scheme,
537                    );
538                }
539            }
540            #[cfg(any(feature = "profile", debug_assertions))]
541            {
542                apply_decl_time += apply_decl.elapsed();
543            }
544
545            if let Background::Image { source, image, .. } = &mut container_style.background {
546                *image = images.get(source).cloned();
547            }
548
549            if container_style.css_float != CssFloat::None && !style.position.kind.is_out_of_flow()
550            {
551                style.display = Display::OutsideInner {
552                    outer: OuterDisplay::Inline,
553                    inner: InnerDisplay::FlowRoot,
554                };
555                style.size.auto_behavior = AutoSizeBehavior::ShrinkToFit;
556            }
557
558            // Absolutely positioned boxes are blockified before layout. The
559            // inner display type remains unchanged.
560            blockify_out_of_flow_positioned(&mut style);
561
562            // Resolve line-height.
563            style.line_height = match text_flow_style.line_height {
564                LineHeight::Number(factor) => Length::Px(text_flow_style.font_size * factor),
565                LineHeight::Normal => Length::Px(text_flow_style.font_size * DEFAULT_LINE_FACTOR),
566                LineHeight::Px(px) => Length::Px(px),
567            };
568            container_style.text_align = text_flow_style.text_align;
569
570            // ── Replaced <iframe> box ──
571            // An iframe renders its content document (grafted into the DOM as
572            // its children) inside a fixed 300×150 viewport that clips overflow,
573            // per the CSS default for the element. Width/height attributes or
574            // CSS override these defaults when supplied.
575            if html_node.tag_name() == Some("iframe") {
576                overflow.x = true;
577                overflow.y = true;
578                if matches!(style.size.width, LengthOrAuto::Auto) {
579                    style.size.width = LengthOrAuto::Length(Length::Px(300.0));
580                }
581                if matches!(style.size.height, LengthOrAuto::Auto) {
582                    style.size.height = LengthOrAuto::Length(Length::Px(150.0));
583                }
584            }
585
586            let child = Arc::new(InheritedCss {
587                custom_props: custom_properties,
588                text_style: text_style.clone(),
589                text_flow_style,
590                color_scheme: used_color_scheme,
591            });
592
593            if let HtmlNodeType::Text(_) = html_node {
594                unreachable!();
595            }
596
597            // ── Custom / replaced element (leaf) ──
598            if let Some(tag) = html_node.tag_name()
599                && registry.tags().contains(&tag)
600            {
601                perf_scope!(custom_node);
602                // Replaced elements (button/img/input) size by their intrinsic
603                // content when auto-sized, not by filling the containing block.
604                style.size.auto_behavior = AutoSizeBehavior::ShrinkToFit;
605                let media_source = html_node.get_attr("src").map(str::to_string).or_else(|| {
606                    snapshot
607                        .children(stack[top_idx].dom)
608                        .iter()
609                        .find_map(|&child| {
610                            let child = &snapshot.node(child).kind;
611                            (child.tag_name() == Some("source"))
612                                .then(|| child.get_attr("src").map(str::to_string))
613                                .flatten()
614                        })
615                });
616                let node = registry
617                    .create(&CustomNodeContext {
618                        tag,
619                        media_source: media_source.as_deref(),
620                        container_style: &container_style,
621                        text_style: &text_style,
622                        measurer: Arc::clone(&measurer),
623                        images,
624                        audio,
625                        get_attr: &|name| html_node.get_attr(name).map(str::to_string),
626                        write_back: write_back_sender
627                            .as_ref()
628                            .map(|sender| (sender.clone(), stack[top_idx].dom)),
629                        dom_snapshot: snapshot,
630                        dom_id: stack[top_idx].dom,
631                    })
632                    .expect("registry must handle every tag it reports");
633
634                let bridge = CustomNodeBridge::new(Arc::clone(&node), style.clone());
635                let kind = NodeKind::Custom {
636                    node,
637                    scroll_x: overflow.x,
638                    scroll_y: overflow.y,
639                    scroll_offset_x: 0.0,
640                    scroll_offset_y: 0.0,
641                    style: container_style,
642                    layout_style: style.clone(),
643                    text_style: text_style.clone(),
644                    text_flow_style,
645                };
646                let layout = LayoutNode::with_children(style.clone(), [(style, bridge)]);
647                let info = InfoNode {
648                    kind,
649                    children: Vec::new(),
650                    dom_id: Some(stack[top_idx].dom),
651                };
652                let ptr = stack[top_idx].dom;
653                results.insert(ptr, (layout, info));
654                #[cfg(any(feature = "profile", debug_assertions))]
655                {
656                    custom_node_time += custom_node.elapsed();
657                    node_count += 1;
658                }
659                stack.pop();
660                continue;
661            }
662
663            // ── Element node ──
664            let is_link = html_node.tag_name() == Some("a") && html_node.get_attr("href").is_some();
665
666            let role = match html_node.tag_name() {
667                Some("table") => ContainerRole::Table,
668                Some("thead" | "tbody" | "tfoot") => ContainerRole::TableRowGroup,
669                Some("tr") => ContainerRole::TableRow,
670                Some("td" | "th") => ContainerRole::TableCell,
671                Some("caption") => ContainerRole::TableCaption,
672                _ if is_link => ContainerRole::Link {
673                    href: html_node.get_attr("href").unwrap().to_string(),
674                },
675                _ => ContainerRole::Normal,
676            };
677
678            let kind = NodeKind::Container {
679                scroll_x: overflow.x,
680                scroll_y: overflow.y,
681                scroll_offset_x: 0.0,
682                scroll_offset_y: 0.0,
683                style: container_style,
684                role,
685            };
686
687            // Table → flex overrides
688            if let Some(tag) = html_node.tag_name() {
689                match tag {
690                    "table" | "tbody" | "thead" | "tfoot" => {
691                        style.display = Display::OutsideInner {
692                            outer: OuterDisplay::Block,
693                            inner: InnerDisplay::Flex,
694                        };
695                        style.flex_direction = FlexDirection::Column;
696                    }
697                    "tr" => {
698                        style.display = Display::OutsideInner {
699                            outer: OuterDisplay::Block,
700                            inner: InnerDisplay::Flex,
701                        };
702                        style.flex_direction = FlexDirection::Row;
703                    }
704                    _ => {}
705                }
706            }
707
708            let mut child_slots: Vec<ChildSlot> = Vec::new();
709            let mut element_kids: Vec<NodeId> = Vec::new();
710
711            perf_scope!(child_slot_build);
712            if style.display != Display::None {
713                let parent_tag_name = snapshot.node(stack[top_idx].dom).kind.tag_name();
714                for &child in snapshot.children(stack[top_idx].dom) {
715                    let child_node = &snapshot.node(child).kind;
716                    if let HtmlNodeType::Text(t) = child_node {
717                        let t = if parent_tag_name == Some("pre") {
718                            let t = t.strip_prefix('\n').unwrap_or(t);
719                            normalize_whitespace(t, text_flow_style.white_space)
720                        } else if t.chars().all(is_css_newline)
721                            && matches!(
722                                text_flow_style.white_space,
723                                WhiteSpace::Normal | WhiteSpace::Nowrap
724                            )
725                        {
726                            continue;
727                        } else {
728                            normalize_whitespace(t, text_flow_style.white_space)
729                        };
730
731                        let t = match text_style.text_transform {
732                            TextTransform::None => t,
733                            TextTransform::Uppercase => t.to_ascii_uppercase(),
734                            TextTransform::Lowercase => t.to_ascii_lowercase(),
735                        };
736                        perf_scope!(text_layout);
737                        let (layouter, kind) =
738                            create_text_node(t, text_style.clone(), text_flow_style, &*measurer);
739                        #[cfg(any(feature = "profile", debug_assertions))]
740                        {
741                            text_layout_time += text_layout.elapsed();
742                            node_count += 1;
743                        }
744                        let mut inline_style = style.clone();
745                        inline_style.display = Display::OutsideInner {
746                            outer: OuterDisplay::Inline,
747                            inner: InnerDisplay::Flow,
748                        };
749                        child_slots.push(ChildSlot::Inline(
750                            (inline_style, layouter).into(),
751                            Box::new(InfoNode {
752                                kind,
753                                children: Vec::new(),
754                                dom_id: Some(child),
755                            }),
756                        ));
757                    } else if child_node.tag_name() == Some("br") {
758                        child_slots.push(ChildSlot::Inline(
759                            ItemFragment::LineBreak.into(),
760                            Box::new(InfoNode {
761                                kind: NodeKind::LineBreak,
762                                children: Vec::new(),
763                                dom_id: Some(child),
764                            }),
765                        ));
766                    } else if child_node.tag_name() == Some("noscript")
767                        && scripting_mode == ScriptingMode::Enabled
768                    {
769                        // Skip
770                    } else {
771                        child_slots.push(ChildSlot::Element(element_kids.len()));
772                        element_kids.push(child);
773                    }
774                }
775            }
776            #[cfg(any(feature = "profile", debug_assertions))]
777            {
778                child_slot_build_time += child_slot_build.elapsed();
779            }
780
781            if element_kids.is_empty() {
782                // ── No element children → leaf, build immediately ──
783                perf_scope!(leaf_assemble);
784                perf_scope!(whitespace_keep);
785                let keep = compute_whitespace_keep(&child_slots, &[]);
786                #[cfg(any(feature = "profile", debug_assertions))]
787                {
788                    whitespace_keep_time += whitespace_keep.elapsed();
789                }
790                let (layout_children, info_children): (Vec<_>, Vec<_>) = child_slots
791                    .into_iter()
792                    .enumerate()
793                    .filter_map(|(i, slot)| {
794                        if !keep[i] {
795                            return None;
796                        }
797                        match slot {
798                            ChildSlot::Inline(layout, info) => Some((layout, *info)),
799                            ChildSlot::Element(_) => None,
800                        }
801                    })
802                    .unzip();
803                let layout = LayoutNode::with_children(style.clone(), layout_children);
804                let info = InfoNode {
805                    kind,
806                    children: info_children,
807                    dom_id: Some(stack[top_idx].dom),
808                };
809                let ptr = stack[top_idx].dom;
810                results.insert(ptr, (layout, info));
811                #[cfg(any(feature = "profile", debug_assertions))]
812                {
813                    leaf_assemble_time += leaf_assemble.elapsed();
814                    node_count += 1;
815                }
816                stack.pop();
817            } else {
818                // ── Has element children → save state, push children ──
819                perf_scope!(push_children);
820                let parent_chain = stack[top_idx].chain.clone();
821                let parent_container = match &kind {
822                    NodeKind::Container { style, .. } => style.clone(),
823                    _ => ContainerStyle::default(),
824                };
825                stack[top_idx].kind = Some(kind);
826                stack[top_idx].parent_style = style.clone();
827                stack[top_idx].parent_container_style = parent_container;
828                stack[top_idx].style = Some(style);
829                stack[top_idx].child = child;
830                stack[top_idx].child_slots = child_slots;
831                stack[top_idx].element_children = element_kids;
832
833                // Build child chains and push frames.
834                // Clone element_kids before the immutable borrow below
835                // so we don't hold &mut stack[] while pushing.
836                let kids_for_push: Vec<_> = {
837                    let f = &stack[top_idx];
838                    f.element_children.clone()
839                };
840                let child_css = Arc::clone(&stack[top_idx].child);
841                perf_scope!(sibling_info);
842                let kid_infos = element_sibling_infos(snapshot, &kids_for_push);
843                #[cfg(any(feature = "profile", debug_assertions))]
844                {
845                    sibling_info_time += sibling_info.elapsed();
846                }
847                let parent_style_for_children = stack[top_idx].parent_style.clone();
848                let parent_container_for_children = stack[top_idx].parent_container_style.clone();
849                for (&kid, info) in kids_for_push.iter().zip(kid_infos).rev() {
850                    stack.push(StackFrame {
851                        dom: kid,
852                        chain: parent_chain.prepend(info),
853                        child: Arc::clone(&child_css),
854                        kind: None,
855                        style: None,
856                        parent_style: parent_style_for_children.clone(),
857                        parent_container_style: parent_container_for_children.clone(),
858                        child_slots: Vec::new(),
859                        element_children: Vec::new(),
860                    });
861                }
862                #[cfg(any(feature = "profile", debug_assertions))]
863                {
864                    push_children_time += push_children.elapsed();
865                }
866            }
867        } else {
868            // ── EXIT phase ────────────────────────────────────────────────
869            // Take ownership of frame data for building results.
870            perf_scope!(exit_phase);
871            perf_scope!(exit_setup);
872
873            let frame = stack.swap_remove(top_idx);
874
875            let mut style = frame.style.as_ref().unwrap().clone();
876            let kind = frame.kind.as_ref().unwrap().clone();
877
878            // Collect element children results.
879            let mut element_results: Vec<(LayoutChild, InfoNode)> = Vec::new();
880
881            for &kid in &frame.element_children {
882                if let Some((child_layout, child_info)) = results.remove(&kid) {
883                    element_results.push((child_layout.into(), child_info));
884                }
885            }
886
887            // Handle html→body background inheritance.
888            let mut final_kind = kind;
889            if snapshot.node(frame.dom).kind.tag_name() == Some("html") {
890                let should_inherit = final_kind.is_container_with_transparent_bg();
891                if should_inherit {
892                    for (i, &kid) in frame.element_children.iter().enumerate() {
893                        if snapshot.node(kid).kind.tag_name() == Some("body")
894                            && i < element_results.len()
895                        {
896                            let child_bg = element_results[i].1.kind.container_bg();
897                            if let Some(bg) = child_bg
898                                && let NodeKind::Container { ref mut style, .. } = final_kind
899                            {
900                                style.background = bg.clone();
901                            }
902                        }
903                    }
904                }
905            }
906
907            #[cfg(any(feature = "profile", debug_assertions))]
908            {
909                exit_setup_time += exit_setup.elapsed();
910            }
911
912            perf_scope!(exit_build);
913            let mut element_results: Vec<_> = element_results.into_iter().map(Some).collect();
914
915            // Whitespace-only text nodes between two block-level siblings, or adjacent
916            // to a `<br>`, would otherwise create stray inline boxes and spurious line
917            // boxes in block, flex, and grid containers. Drop them now that every
918            // sibling's display is resolved.
919            perf_scope!(whitespace_keep);
920            let keep = compute_whitespace_keep(&frame.child_slots, &element_results);
921            #[cfg(any(feature = "profile", debug_assertions))]
922            {
923                whitespace_keep_time += whitespace_keep.elapsed();
924            }
925
926            let mut all_layout: Vec<LayoutChild> = Vec::with_capacity(frame.child_slots.len());
927            let mut all_info: Vec<InfoNode> = Vec::with_capacity(frame.child_slots.len());
928
929            for (i, slot) in frame.child_slots.into_iter().enumerate() {
930                if !keep[i] {
931                    continue;
932                }
933                let (lc, ic) = match slot {
934                    ChildSlot::Inline(layout, info) => (layout, *info),
935                    ChildSlot::Element(index) => element_results[index]
936                        .take()
937                        .expect("element child result must exist"),
938                };
939                all_layout.push(lc);
940                all_info.push(ic);
941            }
942
943            // Collapsible whitespace at a block boundary does not create an
944            // anonymous line box. Keeping indentation-only DOM text here made
945            // a block such as Scratch's `.page` start one default line-height
946            // below the viewport.
947            let keep: Vec<bool> = (0..all_layout.len())
948                .map(|index| {
949                    let collapsible = is_collapsible_whitespace_info(&all_info[index]);
950                    let next_to_block = index == 0
951                        || index + 1 == all_layout.len()
952                        || index
953                            .checked_sub(1)
954                            .is_some_and(|previous| is_block_layout_child(&all_layout[previous]))
955                        || all_layout.get(index + 1).is_some_and(is_block_layout_child);
956                    !(collapsible && next_to_block)
957                })
958                .collect();
959            all_layout = all_layout
960                .into_iter()
961                .zip(&keep)
962                .filter_map(|(layout, keep)| keep.then_some(layout))
963                .collect();
964            all_info = all_info
965                .into_iter()
966                .zip(keep)
967                .filter_map(|(info, keep)| keep.then_some(info))
968                .collect();
969
970            // ui_layout currently resolves an auto-width inline flow-root
971            // against all available inline space. Floats are shrink-to-fit
972            // boxes instead. When their contents expose a fixed CSS width,
973            // use that width as the float's content width so carousel slides
974            // do not each expand to the full track width.
975            if style.display
976                == (Display::OutsideInner {
977                    outer: OuterDisplay::Inline,
978                    inner: InnerDisplay::FlowRoot,
979                })
980                && style.size.auto_behavior == AutoSizeBehavior::ShrinkToFit
981                && matches!(style.size.width, LengthOrAuto::Auto)
982                && let Some(width) = maximum_fixed_descendant_width(&all_layout)
983            {
984                style.size.width = LengthOrAuto::Length(Length::Px(width));
985            }
986
987            // Grid and flex items are blockified by CSS Display. Keeping an
988            // inline direct child makes its text-flow coordinates remain in
989            // the parent's inline space, so item placement cannot move the
990            // text with its box.
991            if matches!(
992                style.display.inner(),
993                Some(InnerDisplay::Grid | InnerDisplay::Flex)
994            ) {
995                if style.display.inner() == Some(InnerDisplay::Grid) {
996                    let columns = explicit_grid_track_count(&style.grid_template_columns);
997                    let rows = explicit_grid_track_count(&style.grid_template_rows);
998                    for child in &mut all_layout {
999                        if let LayoutChild::Node(child) = child {
1000                            resolve_named_grid_area(child, &style.grid_template_areas);
1001                            resolve_grid_end_line(&mut child.style.grid_column, columns);
1002                            resolve_grid_end_line(&mut child.style.grid_row, rows);
1003                        }
1004                    }
1005                }
1006                for child in &mut all_layout {
1007                    if let LayoutChild::Node(child) = child
1008                        && child.style.display.outer() == Some(OuterDisplay::Inline)
1009                        && !child.style.position.kind.is_out_of_flow()
1010                        && let Display::OutsideInner { inner, .. } = child.style.display
1011                    {
1012                        child.style.display = Display::OutsideInner {
1013                            outer: OuterDisplay::Block,
1014                            inner,
1015                        };
1016                    }
1017                }
1018            }
1019
1020            let layout = LayoutNode::with_children(style, all_layout);
1021            let info = InfoNode {
1022                kind: final_kind,
1023                children: all_info,
1024                dom_id: Some(frame.dom),
1025            };
1026            let ptr = frame.dom;
1027            results.insert(ptr, (layout, info));
1028            #[cfg(any(feature = "profile", debug_assertions))]
1029            {
1030                exit_build_time += exit_build.elapsed();
1031                exit_phase_time += exit_phase.elapsed();
1032                node_count += 1;
1033            }
1034        }
1035    }
1036
1037    profile_log!(
1038        target: "LayoutRun",
1039        log::Level::Info,
1040        "[LayoutMetrics] total: {:?} (nodes: {})",
1041        total.elapsed(),
1042        node_count,
1043    );
1044    profile_log!(
1045        target: "LayoutRun",
1046        log::Level::Info,
1047        "[LayoutMetrics] css_match: {:?} | apply_decl: {:?}",
1048        css_match_time,
1049        apply_decl_time,
1050    );
1051    profile_log!(
1052        target: "LayoutRun",
1053        log::Level::Info,
1054        "[LayoutCandidates] elements: {} | examined: {} | matched: {}",
1055        cand_stats.elements_checked,
1056        cand_stats.candidates_examined,
1057        cand_stats.selectors_matched,
1058    );
1059    profile_log!(
1060        target: "LayoutRun",
1061        log::Level::Info,
1062        "[LayoutCandidates] query_time: {:?} | sel_match_time: {:?} | insert_time: {:?}",
1063        cand_stats.query_candidates_time,
1064        cand_stats.selector_match_time,
1065        cand_stats.cascade_insert_time,
1066    );
1067    profile_log!(
1068        target: "LayoutRun",
1069        log::Level::Info,
1070        "[LayoutMetrics] custom_node: {:?} | text_layout: {:?} | exit_phase: {:?}",
1071        custom_node_time,
1072        text_layout_time,
1073        exit_phase_time,
1074    );
1075    profile_log!(
1076        target: "LayoutRun",
1077        log::Level::Info,
1078        "[LayoutMetrics] sibling_info: {:?} | whitespace_keep: {:?}",
1079        sibling_info_time,
1080        whitespace_keep_time,
1081    );
1082    profile_log!(
1083        target: "LayoutRun",
1084        log::Level::Info,
1085        "[LayoutMetrics] enter_prep: {:?} | child_slot_build: {:?} | leaf_assemble: {:?}",
1086        enter_prep_time,
1087        child_slot_build_time,
1088        leaf_assemble_time,
1089    );
1090    profile_log!(
1091        target: "LayoutRun",
1092        log::Level::Info,
1093        "[LayoutMetrics] push_children: {:?} | exit_setup: {:?} | exit_build: {:?}",
1094        push_children_time,
1095        exit_setup_time,
1096        exit_build_time,
1097    );
1098    profile_log!(
1099        target: "LayoutRun",
1100        log::Level::Info,
1101        "[LayoutMetrics] rest: {:?}",
1102        total.elapsed().saturating_sub(
1103            css_match_time
1104                + apply_decl_time
1105                + custom_node_time
1106                + enter_prep_time
1107                + child_slot_build_time
1108                + leaf_assemble_time
1109                + push_children_time
1110                + exit_setup_time
1111                + exit_build_time
1112        ),
1113    );
1114
1115    results
1116        .remove(&root)
1117        .expect("root must have been processed")
1118}
1119
1120// ── Whitespace helpers ──────────────────────────────────────────────────────
1121
1122fn is_css_whitespace(c: char) -> bool {
1123    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c')
1124}
1125
1126fn is_css_newline(c: char) -> bool {
1127    matches!(c, '\n' | '\r')
1128}
1129
1130/// True when an inline child is a whitespace-only text node that renders as
1131/// nothing more than a collapsible space.
1132fn is_collapsible_whitespace_text(info: &InfoNode) -> bool {
1133    matches!(
1134        &info.kind,
1135        NodeKind::Text {
1136            text, flow_style, ..
1137        } if text.chars().all(is_css_whitespace)
1138            && matches!(
1139                flow_style.white_space,
1140                WhiteSpace::Normal | WhiteSpace::Nowrap
1141            )
1142    )
1143}
1144
1145/// Classification of the nearest layout-participating sibling of a slot.
1146#[derive(PartialEq)]
1147enum Neighbour {
1148    /// No participating sibling (container edge) or only `display:none` boxes.
1149    None,
1150    /// A block-level element box.
1151    Block,
1152    /// A `<br>` line break.
1153    LineBreak,
1154    /// Any other inline content (text, inline element, replaced, …).
1155    Inline,
1156}
1157
1158/// Inspects the nearest layout-participating sibling of `slot_index` in the
1159/// given `step` direction (`-1` = previous, `+1` = next).
1160///
1161/// Collapsible-whitespace-only text siblings are always skipped. `<br>`
1162/// siblings are skipped when `skip_line_break` is set, so the caller can tell
1163/// "adjacent to a `<br>`" apart from "the nearest block beyond a `<br>`".
1164fn neighbour(
1165    slots: &[ChildSlot],
1166    element_results: &[Option<(LayoutChild, InfoNode)>],
1167    slot_index: usize,
1168    step: isize,
1169    skip_line_break: bool,
1170) -> Neighbour {
1171    let mut idx = slot_index;
1172
1173    loop {
1174        let Some(next) = (if step < 0 {
1175            idx.checked_sub(1)
1176        } else {
1177            idx.checked_add(1)
1178        }) else {
1179            return Neighbour::None;
1180        };
1181        if next >= slots.len() {
1182            return Neighbour::None;
1183        }
1184        idx = next;
1185
1186        match &slots[idx] {
1187            ChildSlot::Element(element_index) => {
1188                let Some((child, _)) = element_results[*element_index].as_ref() else {
1189                    continue;
1190                };
1191
1192                // display:none elements do not participate in layout.
1193                if matches!(
1194                    child,
1195                    LayoutChild::Node(node)
1196                        if node.style.display == Display::None
1197                ) {
1198                    continue;
1199                }
1200
1201                return if matches!(
1202                    child,
1203                    LayoutChild::Node(node)
1204                        if node.style.display.outer() == Some(OuterDisplay::Block)
1205                ) {
1206                    Neighbour::Block
1207                } else {
1208                    Neighbour::Inline
1209                };
1210            }
1211            ChildSlot::Inline(_, info) => {
1212                if matches!(info.kind, NodeKind::LineBreak) {
1213                    if !skip_line_break {
1214                        return Neighbour::LineBreak;
1215                    }
1216                } else if !is_collapsible_whitespace_text(info) {
1217                    return Neighbour::Inline;
1218                }
1219            }
1220        }
1221    }
1222}
1223
1224/// Per-child keep decision for whitespace-only text nodes.
1225///
1226/// A whitespace-only text node is dropped when it is adjacent on *either*
1227/// side to a block-level sibling or to a `<br>`; otherwise it would become a
1228/// stray inline box that creates a spurious line box in block containers.
1229fn compute_whitespace_keep(
1230    slots: &[ChildSlot],
1231    element_results: &[Option<(LayoutChild, InfoNode)>],
1232) -> Vec<bool> {
1233    (0..slots.len())
1234        .map(|i| match &slots[i] {
1235            ChildSlot::Inline(_, info) => {
1236                if !is_collapsible_whitespace_text(info) {
1237                    return true;
1238                }
1239                // Drop if either side is a block-level box or a `<br>` (a line
1240                // break starts a new line, so adjacent whitespace is spurious).
1241                let side_drops = |step: isize| {
1242                    neighbour(slots, element_results, i, step, true) == Neighbour::Block
1243                        || neighbour(slots, element_results, i, step, false) == Neighbour::LineBreak
1244                };
1245                !(side_drops(-1) || side_drops(1))
1246            }
1247            ChildSlot::Element(_) => true,
1248        })
1249        .collect()
1250}
1251
1252// ── Grid layout helpers ─────────────────────────────────────────────────────
1253
1254fn explicit_grid_track_count(tracks: &[GridTrack]) -> usize {
1255    tracks
1256        .iter()
1257        .map(|track| match track {
1258            GridTrack::Repeat(GridRepeat::Count(count), pattern) => {
1259                count.saturating_mul(explicit_grid_track_count(pattern))
1260            }
1261            GridTrack::Repeat(GridRepeat::AutoFit | GridRepeat::AutoFill, _) => 0,
1262            _ => 1,
1263        })
1264        .sum()
1265}
1266
1267fn resolve_grid_end_line(placement: &mut GridPlacement, track_count: usize) {
1268    if !matches!(placement.end, GRID_LINE_TO_END) || track_count == 0 {
1269        return;
1270    }
1271
1272    placement.end = GridPlacementEnd::Line(track_count + 1);
1273}
1274
1275fn resolve_named_grid_area(node: &mut LayoutNode, areas: &[Vec<String>]) {
1276    let Some(name) = node.style.grid_area.as_deref() else {
1277        return;
1278    };
1279    let mut min_column = usize::MAX;
1280    let mut max_column = 0;
1281    let mut min_row = usize::MAX;
1282    let mut max_row = 0;
1283    for (row, names) in areas.iter().enumerate() {
1284        for (column, area) in names.iter().enumerate() {
1285            if area == name {
1286                min_column = min_column.min(column);
1287                max_column = max_column.max(column);
1288                min_row = min_row.min(row);
1289                max_row = max_row.max(row);
1290            }
1291        }
1292    }
1293    if min_column == usize::MAX {
1294        return;
1295    }
1296    node.style.grid_column = GridPlacement {
1297        start: Some(min_column + 1),
1298        end: GridPlacementEnd::Line(max_column + 2),
1299    };
1300    node.style.grid_row = GridPlacement {
1301        start: Some(min_row + 1),
1302        end: GridPlacementEnd::Line(max_row + 2),
1303    };
1304    node.style.grid_area = None;
1305}
1306
1307// ── Text normalization ──────────────────────────────────────────────────────
1308
1309pub fn normalize_whitespace(text: &str, white_space: WhiteSpace) -> String {
1310    let text = text.replace("\r\n", "\n");
1311    let text = text.replace(['\r', '\x0c'], "\n");
1312
1313    let mut result = String::new();
1314    let mut prev_was_space = false;
1315
1316    for c in text.chars() {
1317        match white_space {
1318            WhiteSpace::Normal | WhiteSpace::Nowrap => {
1319                if is_css_whitespace(c) {
1320                    if !prev_was_space {
1321                        result.push(' ');
1322                    }
1323                    prev_was_space = true;
1324                } else {
1325                    result.push(c);
1326                    prev_was_space = false;
1327                }
1328            }
1329
1330            WhiteSpace::Pre | WhiteSpace::PreWrap | WhiteSpace::BreakSpaces => {
1331                result.push(c);
1332                prev_was_space = false;
1333            }
1334
1335            WhiteSpace::PreLine => {
1336                if c == '\n' {
1337                    result.push('\n');
1338                    prev_was_space = false;
1339                } else if is_css_whitespace(c) {
1340                    if !prev_was_space {
1341                        result.push(' ');
1342                    }
1343                    prev_was_space = true;
1344                } else {
1345                    result.push(c);
1346                    prev_was_space = false;
1347                }
1348            }
1349        }
1350    }
1351
1352    if white_space == WhiteSpace::PreLine {
1353        while result.ends_with('\n') {
1354            result.pop();
1355        }
1356    }
1357
1358    result
1359}
1360
1361/// Measure text and create a [`TextFlowLayouter`] + [`NodeKind::Text`].
1362///
1363/// Falls back to unshaped measurement when shaped measurement fails.
1364fn create_text_node(
1365    text: String,
1366    text_style: TextStyle,
1367    text_flow_style: TextFlowStyle,
1368    measurer: &dyn text::TextMeasurer,
1369) -> (TextFlowLayouter, NodeKind) {
1370    perf_scope!(measure);
1371    let request = text::TextMeasureRequest {
1372        text: text.clone(),
1373        attribute: text::TextAttribute {
1374            style: text_style.clone(),
1375            flow_style: text_flow_style,
1376        },
1377    };
1378    let clusters = measurer.measure_shaped(&request).unwrap_or_else(|_| {
1379        measurer
1380            .measure(&request)
1381            .map(|ms| {
1382                let mut offset = 0usize;
1383                ms.into_iter()
1384                    .map(|f| {
1385                        // find this fragment's byte offset in the original text
1386                        let pos = text[offset..]
1387                            .find(&f.text)
1388                            .map(|p| offset + p)
1389                            .unwrap_or(offset);
1390                        offset = pos + f.text.len();
1391                        GlyphCluster {
1392                            byte_offset: pos,
1393                            width: f.width,
1394                            break_allowed: true,
1395                        }
1396                    })
1397                    .collect()
1398            })
1399            .unwrap_or_default()
1400    });
1401    profile_log!(
1402        target: "Layouter",
1403        log::Level::Info,
1404        "measure_shaped: text={:?} len={} took={:?}",
1405        crate::profile::text_preview(&text),
1406        text.len(),
1407        measure.elapsed(),
1408    );
1409
1410    let layouter = TextFlowLayouter::new(text.clone(), text_flow_style, clusters);
1411    let kind = NodeKind::Text {
1412        text,
1413        style: text_style,
1414        flow_style: text_flow_style,
1415        text_id: layouter.id,
1416    };
1417    (layouter, kind)
1418}
1419
1420// ── Color scheme resolution ─────────────────────────────────────────────────
1421
1422/// How an element's `color-scheme` property constrains its used color scheme.
1423enum ColorSchemePref {
1424    /// `normal` or unset: fall back to the inherited (or system) scheme.
1425    Normal,
1426    Light,
1427    Dark,
1428    /// Both `light` and `dark` listed: follow the system preference.
1429    Both,
1430}
1431
1432/// Parses the winning `color-scheme` declaration into a preference.
1433fn color_scheme_pref(value: Option<&CssValue>) -> ColorSchemePref {
1434    let Some(value) = value else {
1435        return ColorSchemePref::Normal;
1436    };
1437    let mut light = false;
1438    let mut dark = false;
1439    let mut has = false;
1440    let mut push = |keyword: &str| {
1441        has = true;
1442        match keyword {
1443            "light" => light = true,
1444            "dark" => dark = true,
1445            // `only`, `normal`, unknown keywords are ignored here.
1446            _ => {}
1447        }
1448    };
1449    match value {
1450        CssValue::Keyword(k) => push(k),
1451        CssValue::List(items) => {
1452            for item in items {
1453                if let CssValue::Keyword(k) = item {
1454                    push(k);
1455                }
1456            }
1457        }
1458        _ => {}
1459    }
1460    if !has {
1461        ColorSchemePref::Normal
1462    } else if light && dark {
1463        ColorSchemePref::Both
1464    } else if light {
1465        ColorSchemePref::Light
1466    } else if dark {
1467        ColorSchemePref::Dark
1468    } else {
1469        ColorSchemePref::Normal
1470    }
1471}
1472
1473/// Computes the used color scheme of an element from its `color-scheme`
1474/// declaration, the inherited scheme, and the system preference.
1475fn resolve_used_color_scheme(
1476    declaration: Option<&CssValue>,
1477    inherited: ColorScheme,
1478    system: ColorScheme,
1479) -> ColorScheme {
1480    match color_scheme_pref(declaration) {
1481        ColorSchemePref::Normal => inherited,
1482        ColorSchemePref::Light => ColorScheme::Light,
1483        ColorSchemePref::Dark => ColorScheme::Dark,
1484        ColorSchemePref::Both => system,
1485    }
1486}
1487
1488// ── CSS candidate collection ────────────────────────────────────────────────
1489
1490/// Aggregated CSS candidate-matching statistics for a single layout build.
1491#[cfg(any(feature = "profile", debug_assertions))]
1492#[derive(Default)]
1493struct CandidateMetrics {
1494    elements_checked: u64,
1495    candidates_examined: u64,
1496    selectors_matched: u64,
1497    query_candidates_time: std::time::Duration,
1498    selector_match_time: std::time::Duration,
1499    cascade_insert_time: std::time::Duration,
1500}
1501
1502fn collect_candidates(
1503    rule_set: &RuleSet,
1504    chain: &ElementChain,
1505    #[cfg(any(feature = "profile", debug_assertions))] stats: &mut CandidateMetrics,
1506) -> (Properties, Properties) {
1507    let mut properties = HashMap::new();
1508    let mut custom_properties = HashMap::new();
1509
1510    let element = match chain.first() {
1511        Some(el) => el,
1512        None => return (properties, custom_properties),
1513    };
1514
1515    #[cfg(any(feature = "profile", debug_assertions))]
1516    {
1517        stats.elements_checked += 1;
1518    }
1519
1520    // The candidate iterator is lazy; under profiling it is materialized so
1521    // query time is measured separately from selector matching.
1522    perf_scope!(query);
1523    #[cfg(any(feature = "profile", debug_assertions))]
1524    let candidates_iter: Vec<_> = rule_set.query_candidates(element).collect();
1525    #[cfg(not(any(feature = "profile", debug_assertions)))]
1526    let candidates_iter = rule_set.query_candidates(element);
1527    #[cfg(any(feature = "profile", debug_assertions))]
1528    {
1529        stats.query_candidates_time += query.elapsed();
1530    }
1531
1532    for group in candidates_iter {
1533        #[cfg(any(feature = "profile", debug_assertions))]
1534        {
1535            stats.candidates_examined += 1;
1536        }
1537
1538        // Declarations sharing an identical selector are grouped, so the
1539        // (comparatively expensive) selector walk happens once per group.
1540        perf_scope!(sel_match);
1541        let matches_sel = group.selector.matches(chain);
1542        #[cfg(any(feature = "profile", debug_assertions))]
1543        {
1544            stats.selector_match_time += sel_match.elapsed();
1545            if matches_sel {
1546                stats.selectors_matched += 1;
1547            }
1548        }
1549        if !matches_sel {
1550            continue;
1551        }
1552
1553        for &decl_idx in &group.decls {
1554            let decl = &rule_set.declarations()[decl_idx];
1555
1556            let target = if decl.name.starts_with("--") {
1557                &mut custom_properties
1558            } else {
1559                &mut properties
1560            };
1561
1562            perf_scope!(cascade);
1563            let should_replace = match target.get(&decl.name) {
1564                Some(current) => decl.outranks(current),
1565                None => true,
1566            };
1567
1568            if should_replace {
1569                target.insert(decl.name.clone(), decl.clone());
1570            }
1571
1572            #[cfg(any(feature = "profile", debug_assertions))]
1573            {
1574                stats.cascade_insert_time += cascade.elapsed();
1575            }
1576        }
1577    }
1578
1579    (properties, custom_properties)
1580}