9677 lines
401 KiB
Rust
9677 lines
401 KiB
Rust
//! 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<NodeId, Rect>,
|
|
pub styles: HashMap<NodeId, crate::LayoutStyle>,
|
|
/// 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<NodeId, Option<Rect>>,
|
|
/// 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<NodeId, (f32, f32)>,
|
|
/// 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<NodeId, Vec<(Rect, String)>>,
|
|
/// 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<NodeId, usize>,
|
|
/// 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<NodeId, Vec<usize>>,
|
|
/// 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<GeneratedBox>,
|
|
}
|
|
|
|
/// 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<StickyFrame>,
|
|
owners: HashMap<NodeId, NodeId>,
|
|
clip_owners: HashMap<NodeId, NodeId>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct StickyFrame {
|
|
id: NodeId,
|
|
parent_sticky: Option<NodeId>,
|
|
normal: Rect,
|
|
containing: Rect,
|
|
margin: crate::Edges,
|
|
inset: [Option<f32>; 4],
|
|
}
|
|
|
|
impl StickyLayout {
|
|
fn frame_offsets(
|
|
&self,
|
|
viewport: (f32, f32),
|
|
scroll: (f32, f32),
|
|
) -> HashMap<NodeId, (f32, f32)> {
|
|
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<NodeId, (f32, f32)> {
|
|
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<NodeId, (f32, f32)>,
|
|
) -> HashMap<NodeId, (f32, f32)> {
|
|
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<f32>,
|
|
end: Option<f32>,
|
|
) -> 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<NodeId> {
|
|
let mut fixed = HashSet::new();
|
|
let mut has_fixed_cb: HashMap<NodeId, bool> = 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<NodeId, Option<NodeId>> = HashMap::new();
|
|
let mut inherited_clip_sticky: HashMap<NodeId, Option<NodeId>> = HashMap::new();
|
|
let mut inside_nested_scroller: HashMap<NodeId, bool> = 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<crate::Dimension>, 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<Rect>,
|
|
fixed: &HashSet<NodeId>,
|
|
rects: &HashMap<NodeId, Rect>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
translates: &HashMap<NodeId, (f32, f32)>,
|
|
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<usize>,
|
|
taffy_root: taffy::NodeId,
|
|
initial_containing_block_width: f32,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
generated: &[GeneratedBoxBuild],
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
) {
|
|
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<usize>,
|
|
node: taffy::NodeId,
|
|
containing_block_width: f32,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
generated: &HashMap<taffy::NodeId, (NodeId, GeneratedBoxKind)>,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
) {
|
|
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<NodeId, Rect>,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
) {
|
|
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<NodeId, usize>,
|
|
runs: HashMap<NodeId, Vec<usize>>,
|
|
generated: Vec<GeneratedBoxBuild>,
|
|
/// Specified column widths per table grid node, from `<col>` 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<taffy::NodeId, (Vec<Option<f32>>, Vec<Option<f32>>)>,
|
|
/// 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<taffy::NodeId, Vec<Option<f32>>>,
|
|
/// 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<FloatContinuation>,
|
|
/// 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<MulticolBuild>,
|
|
}
|
|
|
|
struct MulticolBuild {
|
|
columns: Vec<taffy::NodeId>,
|
|
children: Vec<taffy::NodeId>,
|
|
}
|
|
|
|
#[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<Rect>,
|
|
tx: f32,
|
|
ty: f32,
|
|
rects: &HashMap<NodeId, Rect>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
clip_rects: &mut HashMap<NodeId, Option<Rect>>,
|
|
translates: &mut HashMap<NodeId, (f32, f32)>,
|
|
) {
|
|
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::<f32>().ok());
|
|
let ah = node.get_attribute("height").and_then(|h| h.parse::<f32>().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<f32> = view_box
|
|
.split(|c: char| c.is_ascii_whitespace() || c == ',')
|
|
.filter(|part| !part.is_empty())
|
|
.filter_map(|part| part.parse::<f32>().ok())
|
|
.collect();
|
|
if values.len() == 4 && values[2] > 0.0 && values[3] > 0.0 {
|
|
style.aspect_ratio = Some(values[2] / values[3]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The selected `<picture><source>` contributes presentation hints to its
|
|
/// associated `<img>`. 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::<f32>().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 <img> 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<NodeId, crate::LayoutStyle>,
|
|
parent_props: &std::rc::Rc<HashMap<String, String>>,
|
|
container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>,
|
|
quirks_mode: bool,
|
|
viewport: (f32, f32),
|
|
inherited_cell_padding: Option<f32>,
|
|
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::<f32>().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<String> = 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 `<img>` 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<NodeId, (f32, f32)>,
|
|
) -> 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<NodeId, (f32, f32)>,
|
|
fonts: &[Vec<u8>],
|
|
) -> 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<T: PartialEq>(
|
|
geometry_stable: bool,
|
|
signature: &T,
|
|
previous_signature: Option<&T>,
|
|
) -> Option<ContainerLayoutTermination> {
|
|
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<NodeId, (f32, f32)>,
|
|
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<NodeId, (f32, f32)>,
|
|
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<NodeId, (f32, f32)>,
|
|
fonts: &[crate::inline::WebFont],
|
|
pass_limit: Option<usize>,
|
|
) -> (DomLayout, ContainerLayoutTelemetry) {
|
|
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
|
|
|
// Collect the text of every <style> block in document order.
|
|
let mut css_sources = Vec::new();
|
|
for nid in tree.descendants(tree.document()) {
|
|
if let Some(node) = tree.get_node(nid) {
|
|
if let Some(elem) = node.as_element() {
|
|
if elem.local.as_ref() == "style" {
|
|
css_sources.push(tree.text_content(nid));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let t0 = std::time::Instant::now();
|
|
let sheet = crate::css::Stylesheet::parse_for_viewport(tree, &css_sources, viewport);
|
|
let t_parse = t0.elapsed();
|
|
|
|
let (mut laid, _, mut query, mut cascade_time) = layout_dom_once(
|
|
tree,
|
|
viewport,
|
|
intrinsic,
|
|
fonts,
|
|
&sheet,
|
|
None,
|
|
);
|
|
if !sheet.has_container_queries() {
|
|
if timing {
|
|
let (r, i, c, l, u) = sheet.debug_stats();
|
|
eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-queries", t_parse, cascade_time, r, i, c, l, u);
|
|
}
|
|
return (
|
|
laid,
|
|
ContainerLayoutTelemetry {
|
|
passes: 1,
|
|
termination: ContainerLayoutTermination::NoQueries,
|
|
query,
|
|
},
|
|
);
|
|
}
|
|
|
|
let mut snapshot = container_snapshot(tree, &laid);
|
|
let mut previous_candidate: Option<(
|
|
DomLayout,
|
|
crate::css::ContainerDecisionSignature,
|
|
)> = None;
|
|
let mut seen_signatures = Vec::new();
|
|
let mut passes = 1;
|
|
let mut termination = ContainerLayoutTermination::PassCapFallback;
|
|
// Gecko permits at most one CQ-triggered update per container element in
|
|
// one flush and processes ancestors before descendants. Our whole-tree
|
|
// passes need the same order of growth: a chain can legitimately reveal
|
|
// one deeper query container per pass. Scale the useful bound with DOM
|
|
// ancestry and nested conditional depth, retaining a high safety limit
|
|
// against adversarial non-convergence. Hitting it is visible telemetry and
|
|
// uses the conservative fallback below, never a silently stale layout.
|
|
// `descendants` is preorder, so every parent depth is available before
|
|
// its children. Keep this O(nodes): walking every ancestor separately
|
|
// makes a deeply nested document quadratic before layout even starts.
|
|
let mut element_depths = HashMap::new();
|
|
element_depths.insert(tree.document(), 0usize);
|
|
let mut max_dom_depth = 1usize;
|
|
for id in tree.descendants(tree.document()) {
|
|
let Some(node) = tree.get_node(id) else {
|
|
continue;
|
|
};
|
|
let parent_depth = node
|
|
.parent
|
|
.and_then(|parent| element_depths.get(&parent).copied())
|
|
.unwrap_or(0);
|
|
let depth = parent_depth + usize::from(node.is_element());
|
|
element_depths.insert(id, depth);
|
|
max_dom_depth = max_dom_depth.max(depth);
|
|
}
|
|
let max_passes = pass_limit.unwrap_or_else(|| {
|
|
(max_dom_depth + sheet.container_condition_depth() + 2)
|
|
.clamp(4, CONTAINER_LAYOUT_SAFETY_LIMIT)
|
|
});
|
|
let mut needs_fallback = false;
|
|
for pass in 2..=max_passes {
|
|
let (next, signature, pass_query, pass_cascade) = layout_dom_once(
|
|
tree,
|
|
viewport,
|
|
intrinsic,
|
|
fonts,
|
|
&sheet,
|
|
Some(&snapshot),
|
|
);
|
|
passes = pass;
|
|
query.evaluations += pass_query.evaluations;
|
|
query.cache_hits += pass_query.cache_hits;
|
|
query.ancestor_steps += pass_query.ancestor_steps;
|
|
cascade_time += pass_cascade;
|
|
let next_snapshot = container_snapshot(tree, &next);
|
|
let signature = signature.expect("container pass must produce a signature");
|
|
if let Some(reason) = container_iteration_termination(
|
|
next_snapshot == snapshot,
|
|
&signature,
|
|
previous_candidate
|
|
.as_ref()
|
|
.map(|(_, signature)| signature),
|
|
) {
|
|
termination = reason;
|
|
// Equal adjacent signatures prove that the *previous* candidate's
|
|
// applied decisions match an evaluation of its own final
|
|
// snapshot. Returning `next` here would be off by one and could
|
|
// expose styles evaluated against geometry it no longer has.
|
|
laid = if reason == ContainerLayoutTermination::SignatureStable {
|
|
previous_candidate
|
|
.take()
|
|
.expect("stable signature requires a previous candidate")
|
|
.0
|
|
} else {
|
|
next
|
|
};
|
|
break;
|
|
}
|
|
if seen_signatures.contains(&signature) {
|
|
termination = ContainerLayoutTermination::OscillationFallback;
|
|
needs_fallback = true;
|
|
break;
|
|
}
|
|
seen_signatures.push(signature.clone());
|
|
previous_candidate = Some((next, signature));
|
|
snapshot = next_snapshot;
|
|
if pass == max_passes {
|
|
needs_fallback = true;
|
|
}
|
|
}
|
|
|
|
if needs_fallback {
|
|
// Author-controlled CSS must never crash rendering, but neither may
|
|
// we return a layout whose conditional declarations contradict the
|
|
// geometry used to choose them. Disable the unstable conditional
|
|
// rules for this render and expose the downgrade in telemetry.
|
|
let (fallback, _, fallback_query, fallback_cascade) = layout_dom_once(
|
|
tree,
|
|
viewport,
|
|
intrinsic,
|
|
fonts,
|
|
&sheet,
|
|
None,
|
|
);
|
|
laid = fallback;
|
|
passes += 1;
|
|
query.evaluations += fallback_query.evaluations;
|
|
query.cache_hits += fallback_query.cache_hits;
|
|
query.ancestor_steps += fallback_query.ancestor_steps;
|
|
cascade_time += fallback_cascade;
|
|
}
|
|
|
|
if timing {
|
|
let (r, i, c, l, u) = sheet.debug_stats();
|
|
eprintln!("[timing] parse+index={:?} cascade_total={:?} rules={} id_keys={} class_keys={} local_keys={} universal={} cq_passes={} cq_termination={:?} cq_evaluations={} cq_cache_hits={} cq_ancestor_steps={}", t_parse, cascade_time, r, i, c, l, u, passes, termination, query.evaluations, query.cache_hits, query.ancestor_steps);
|
|
}
|
|
(
|
|
laid,
|
|
ContainerLayoutTelemetry {
|
|
passes,
|
|
termination,
|
|
query,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn layout_dom_once(
|
|
tree: &DomTree,
|
|
viewport: (f32, f32),
|
|
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
|
fonts: &[crate::inline::WebFont],
|
|
sheet: &crate::css::Stylesheet,
|
|
snapshot: Option<&crate::css::ContainerSnapshot>,
|
|
) -> (
|
|
DomLayout,
|
|
Option<crate::css::ContainerDecisionSignature>,
|
|
crate::css::ContainerQueryStats,
|
|
std::time::Duration,
|
|
) {
|
|
let t1 = std::time::Instant::now();
|
|
let mut matcher = tree.matcher();
|
|
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
|
|
let root_props = std::rc::Rc::new(HashMap::new());
|
|
let mut evaluator =
|
|
snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot));
|
|
let mut evaluator_ref = evaluator.as_mut();
|
|
// A real preorder walk (not a flat descendants() scan) so the matcher's
|
|
// ancestor bloom filter tracks the current path: push before recursing
|
|
// into children, pop on the way back out. This is what lets descendant
|
|
// combinators (".mw-body .firstHeading") fast-reject via the filter
|
|
// instead of falling back to the always-true "can't reject" case.
|
|
let quirks_mode = !tree
|
|
.descendants(tree.document())
|
|
.into_iter()
|
|
.any(|id| {
|
|
tree.get_node(id).map_or(false, |node| {
|
|
matches!(node.data, obscura_dom::tree::NodeData::Doctype { .. })
|
|
})
|
|
});
|
|
cascade_walk(
|
|
tree,
|
|
tree.document(),
|
|
&sheet,
|
|
&mut matcher,
|
|
&mut styles,
|
|
&root_props,
|
|
&mut evaluator_ref,
|
|
quirks_mode,
|
|
viewport,
|
|
None,
|
|
false,
|
|
);
|
|
let cascade_time = t1.elapsed();
|
|
let (signature, query_stats) = evaluator.map_or_else(
|
|
|| (None, crate::css::ContainerQueryStats::default()),
|
|
|evaluator| {
|
|
let (signature, stats) = evaluator.finish();
|
|
(Some(signature), stats)
|
|
},
|
|
);
|
|
grow_trailing_auto_cells(tree, &mut styles);
|
|
|
|
// The leaf context is the index of a cosmic-text inline formatting
|
|
// context in `engine`; leaves without text carry no context.
|
|
let mut taffy_tree: TaffyTree<usize> = TaffyTree::new();
|
|
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
|
|
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
|
|
let mut engine = crate::inline::TextEngine::new_with_web_fonts(fonts);
|
|
let mut ifc_items = IfcRegistry::default();
|
|
|
|
// The document node itself is not an element; lay out from the first
|
|
// element descendant (the <html> root).
|
|
let root = tree
|
|
.descendants(tree.document())
|
|
.into_iter()
|
|
.find(|id| tree.get_node(*id).map(|n| n.is_element()).unwrap_or(false));
|
|
|
|
let mut rects = HashMap::new();
|
|
let mut text_runs = HashMap::new();
|
|
// Final absolute rects of anonymous inline-run leaves, keyed by the
|
|
// engine item index (they have no DOM id to key `rects` by).
|
|
let mut anon_rects: HashMap<usize, Rect> = HashMap::new();
|
|
let mut generated_rects: Vec<Option<Rect>> = Vec::new();
|
|
if let Some(root_id) = root {
|
|
// Headless Chromium's classic scrollbar gutter is 15 CSS pixels.
|
|
// `scrollbar-gutter:stable` reserves it even when the scrollbar track
|
|
// is hidden, and Gecko subtracts the platform gutter from the viewport
|
|
// containing block. Media queries and vw still see the full visual
|
|
// viewport; percentages and auto widths use this reduced ICB.
|
|
const CLASSIC_SCROLLBAR_GUTTER: f32 = 15.0;
|
|
let root_gutters = styles
|
|
.get(&root_id)
|
|
.map_or(0, |style| style.scrollbar_gutters.min(2));
|
|
let initial_cb_width =
|
|
(viewport.0 - CLASSIC_SCROLLBAR_GUTTER * f32::from(root_gutters)).max(0.0);
|
|
let initial_cb_x = if root_gutters == 2 {
|
|
CLASSIC_SCROLLBAR_GUTTER
|
|
} else {
|
|
0.0
|
|
};
|
|
// Top-down inheritance of the properties CSS inherits by default.
|
|
#[derive(Clone)]
|
|
struct Inherited {
|
|
color: Option<[u8; 4]>,
|
|
font_size: Option<f32>,
|
|
font_weight: u16,
|
|
font_family: Option<String>,
|
|
font_optical_sizing: crate::FontOpticalSizing,
|
|
font_variation_settings: Vec<crate::FontVariationSetting>,
|
|
letter_spacing: f32,
|
|
letter_spacing_non_normal: bool,
|
|
container_type: crate::ContainerType,
|
|
container_names: Vec<String>,
|
|
text_align: Option<taffy::AlignItems>,
|
|
legacy_center: bool,
|
|
visibility_hidden: bool,
|
|
opacity_product: f32,
|
|
list_style: crate::ListStyle,
|
|
line_height: crate::LineHeight,
|
|
text_wrap_style: crate::TextWrapStyle,
|
|
text_transform: crate::TextTransform,
|
|
italic: bool,
|
|
box_sizing: crate::BoxSizing,
|
|
border_collapse: bool,
|
|
table_vertical_align: Option<crate::VerticalAlign>,
|
|
/// Containing-block width in px for the current element, carried
|
|
/// down so percentage padding/margin (which resolve against the
|
|
/// containing block WIDTH, all sides) can be turned into px before
|
|
/// taffy layout. Not a CSS-inherited property; it is recomputed to
|
|
/// the element's own content width for its children.
|
|
cb_width: f32,
|
|
/// Whether the containing block has a definite height. Percentage
|
|
/// heights in ordinary flow compute to auto when this is false;
|
|
/// resolving them against a synthetic zero height collapses
|
|
/// content-heavy modern UI wrappers such as code editors.
|
|
cb_height_definite: bool,
|
|
}
|
|
impl Default for Inherited {
|
|
fn default() -> Self {
|
|
Inherited {
|
|
color: None,
|
|
font_size: None,
|
|
font_weight: 400,
|
|
font_family: None,
|
|
font_optical_sizing: crate::FontOpticalSizing::Auto,
|
|
font_variation_settings: Vec::new(),
|
|
letter_spacing: 0.0,
|
|
letter_spacing_non_normal: false,
|
|
container_type: crate::ContainerType::Normal,
|
|
container_names: Vec::new(),
|
|
text_align: None,
|
|
legacy_center: false,
|
|
visibility_hidden: false,
|
|
opacity_product: 1.0,
|
|
// CSS initial value of list-style-type.
|
|
list_style: crate::ListStyle::Disc,
|
|
line_height: crate::LineHeight::Normal,
|
|
text_wrap_style: crate::TextWrapStyle::Auto,
|
|
text_transform: crate::TextTransform::None,
|
|
italic: false,
|
|
box_sizing: crate::BoxSizing::ContentBox,
|
|
border_collapse: false,
|
|
table_vertical_align: None,
|
|
cb_width: 0.0,
|
|
cb_height_definite: false,
|
|
}
|
|
}
|
|
}
|
|
// Viewport-per-unit for vw/vh, and the root font-size for rem. em/rem/
|
|
// vw/vh lengths were parsed unresolved (the element font-size and
|
|
// viewport are not known at parse time); resolve them here, top-down,
|
|
// where the computed font-size is available.
|
|
let vw = viewport.0 / 100.0;
|
|
let vh = viewport.1 / 100.0;
|
|
let root_fs = {
|
|
let s = styles.get(&root_id);
|
|
match (
|
|
s.and_then(|s| s.font_size),
|
|
s.and_then(|s| s.font_size_raw),
|
|
s.and_then(|s| s.font_size_expression.as_deref()),
|
|
) {
|
|
(Some(px), _, _) => px,
|
|
(None, _, Some(expression)) => {
|
|
crate::style::resolve_contextual_length(
|
|
expression,
|
|
16.0,
|
|
16.0,
|
|
vw,
|
|
vh,
|
|
16.0,
|
|
)
|
|
.unwrap_or(16.0)
|
|
}
|
|
(None, Some(d), _) => match d.resolve(16.0, 16.0, vw, vh) {
|
|
crate::Dimension::Px(p) => p,
|
|
crate::Dimension::Percent(p) => 16.0 * p,
|
|
_ => 16.0,
|
|
},
|
|
_ => 16.0,
|
|
}
|
|
};
|
|
|
|
// The root element's containing block is the initial containing block,
|
|
// i.e. the viewport width.
|
|
let mut root_inh = Inherited::default();
|
|
root_inh.cb_width = initial_cb_width;
|
|
root_inh.cb_height_definite = true;
|
|
let mut queue = vec![(root_id, root_inh)];
|
|
while let Some((id, mut inh)) = queue.pop() {
|
|
// Default the child containing-block width to this element's own
|
|
// (updated to its content width inside the block below).
|
|
let mut child_cb_width = inh.cb_width;
|
|
let mut child_cb_height_definite = false;
|
|
if let Some(style) = styles.get_mut(&id) {
|
|
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
|
// Resolve a relative font-size against the PARENT (em/%) or
|
|
// ROOT (rem) font-size before inheriting it downward.
|
|
let parent_fs = inh.font_size.unwrap_or(16.0);
|
|
if let Some(expression) = style.font_size_expression.as_deref() {
|
|
style.font_size = crate::style::resolve_contextual_length(
|
|
expression,
|
|
parent_fs,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
parent_fs,
|
|
);
|
|
} else if let Some(raw) = style.font_size_raw {
|
|
let resolved = match raw {
|
|
crate::Dimension::Percent(p) => parent_fs * p,
|
|
crate::Dimension::Em(v) => parent_fs * v,
|
|
d => match d.resolve(parent_fs, root_fs, vw, vh) {
|
|
crate::Dimension::Px(p) => p,
|
|
_ => parent_fs,
|
|
},
|
|
};
|
|
style.font_size = Some(resolved);
|
|
}
|
|
match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size }
|
|
// em in non-font-size properties is relative to this element's
|
|
// OWN computed font-size; resolve every relative length now.
|
|
let em_px = style.font_size.unwrap_or(parent_fs);
|
|
if let Some(expression) = style.letter_spacing_expression.as_deref() {
|
|
style.letter_spacing = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
em_px,
|
|
);
|
|
} else if let Some(raw) = style.letter_spacing_raw {
|
|
style.letter_spacing = match raw.resolve(em_px, root_fs, vw, vh) {
|
|
crate::Dimension::Px(pixels) if pixels.is_finite() => Some(pixels),
|
|
_ => None,
|
|
};
|
|
}
|
|
match style.letter_spacing {
|
|
Some(spacing) if spacing.is_finite() => inh.letter_spacing = spacing,
|
|
_ => style.letter_spacing = Some(inh.letter_spacing),
|
|
}
|
|
match style.letter_spacing_non_normal {
|
|
Some(non_normal) => inh.letter_spacing_non_normal = non_normal,
|
|
None => {
|
|
style.letter_spacing_non_normal =
|
|
Some(inh.letter_spacing_non_normal)
|
|
}
|
|
}
|
|
if style.container_type_inherit {
|
|
style.container_type = inh.container_type;
|
|
}
|
|
if style.container_names_inherit {
|
|
style.container_names.clone_from(&inh.container_names);
|
|
}
|
|
inh.container_type = style.container_type;
|
|
inh.container_names.clone_from(&style.container_names);
|
|
if let Some(expression) = style.row_gap_expression.as_deref() {
|
|
style.row_gap = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
inh.cb_width,
|
|
);
|
|
}
|
|
if let Some(expression) = style.column_gap_expression.as_deref() {
|
|
style.column_gap = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
inh.cb_width,
|
|
);
|
|
}
|
|
if let Some(expression) = style.line_height_expression.as_deref() {
|
|
if let Some(resolved) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
em_px,
|
|
) {
|
|
style.line_height = Some(
|
|
if crate::style::line_height_expression_is_length(
|
|
expression,
|
|
) {
|
|
crate::LineHeight::Px(resolved)
|
|
} else {
|
|
crate::LineHeight::Ratio(resolved)
|
|
},
|
|
);
|
|
}
|
|
} else if let Some(crate::LineHeight::Relative(relative)) =
|
|
style.line_height
|
|
{
|
|
let pixels = match relative {
|
|
crate::Dimension::Percent(percent) => em_px * percent,
|
|
dimension => match dimension.resolve(
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
) {
|
|
crate::Dimension::Px(pixels) => pixels,
|
|
_ => em_px,
|
|
},
|
|
};
|
|
style.line_height = Some(crate::LineHeight::Px(pixels));
|
|
}
|
|
let cb_w = inh.cb_width;
|
|
for index in 0..6 {
|
|
let Some(expression) = style.size_expressions[index].as_deref() else {
|
|
continue;
|
|
};
|
|
let percent_base = if matches!(index, 1 | 3 | 5) {
|
|
viewport.1
|
|
} else {
|
|
cb_w
|
|
};
|
|
let Some(px) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
percent_base,
|
|
) else {
|
|
continue;
|
|
};
|
|
let resolved = crate::Dimension::Px(px);
|
|
match index {
|
|
0 => style.width = resolved,
|
|
1 => style.height = resolved,
|
|
2 => style.min_width = resolved,
|
|
3 => style.min_height = resolved,
|
|
4 => style.max_width = resolved,
|
|
_ => style.max_height = resolved,
|
|
}
|
|
}
|
|
style.width = style.width.resolve(em_px, root_fs, vw, vh);
|
|
style.height = style.height.resolve(em_px, root_fs, vw, vh);
|
|
style.min_width = style.min_width.resolve(em_px, root_fs, vw, vh);
|
|
style.min_height = style.min_height.resolve(em_px, root_fs, vw, vh);
|
|
style.max_width = style.max_width.resolve(em_px, root_fs, vw, vh);
|
|
style.max_height = style.max_height.resolve(em_px, root_fs, vw, vh);
|
|
style.flex_basis = style.flex_basis.resolve(em_px, root_fs, vw, vh);
|
|
if matches!(style.height, crate::Dimension::Percent(_))
|
|
&& !inh.cb_height_definite
|
|
&& !matches!(style.position, Some(taffy::Position::Absolute))
|
|
{
|
|
style.height = crate::Dimension::Auto;
|
|
}
|
|
child_cb_height_definite = matches!(
|
|
style.height,
|
|
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
|
|
);
|
|
for index in 0..4 {
|
|
let Some(expression) =
|
|
style.inset_expressions[index].as_deref()
|
|
else {
|
|
continue;
|
|
};
|
|
let percent_base = if matches!(index, 1 | 3) {
|
|
cb_w
|
|
} else {
|
|
viewport.1
|
|
};
|
|
style.inset[index] =
|
|
crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
percent_base,
|
|
)
|
|
.map(crate::Dimension::Px);
|
|
}
|
|
for i in style.inset.iter_mut() {
|
|
if let Some(d) = i {
|
|
*i = Some(d.resolve(em_px, root_fs, vw, vh));
|
|
}
|
|
}
|
|
// A fixed box is positioned against the initial containing
|
|
// block, whose dimensions are the viewport, not the full
|
|
// scrollable root element. Taffy attaches out-of-flow boxes to
|
|
// a layout node, and the root node can grow to the document's
|
|
// full content height. Make the CSS 2.1 stretch equation
|
|
// explicit for the ubiquitous `position:fixed; inset:0` case
|
|
// so overlays/canvases remain exactly viewport-sized.
|
|
if style.position_fixed {
|
|
if style.width == crate::Dimension::Auto {
|
|
if let (
|
|
Some(crate::Dimension::Px(right)),
|
|
Some(crate::Dimension::Px(left)),
|
|
) = (style.inset[1], style.inset[3])
|
|
{
|
|
style.width =
|
|
crate::Dimension::Px((initial_cb_width - left - right).max(0.0));
|
|
}
|
|
}
|
|
if style.height == crate::Dimension::Auto {
|
|
if let (
|
|
Some(crate::Dimension::Px(top)),
|
|
Some(crate::Dimension::Px(bottom)),
|
|
) = (style.inset[0], style.inset[2])
|
|
{
|
|
style.height = crate::Dimension::Px(
|
|
(viewport.1 - top - bottom).max(0.0),
|
|
);
|
|
}
|
|
}
|
|
child_cb_height_definite = matches!(
|
|
style.height,
|
|
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
|
|
);
|
|
}
|
|
let computed_weight = crate::style::computed_font_weight(
|
|
style.font_weight.as_deref(),
|
|
inh.font_weight,
|
|
);
|
|
style.font_weight = Some(computed_weight.to_string());
|
|
inh.font_weight = computed_weight;
|
|
match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() }
|
|
match style.font_optical_sizing {
|
|
Some(value) => inh.font_optical_sizing = value,
|
|
None => style.font_optical_sizing = Some(inh.font_optical_sizing),
|
|
}
|
|
match &style.font_variation_settings {
|
|
Some(settings) => inh.font_variation_settings.clone_from(settings),
|
|
None => {
|
|
style.font_variation_settings =
|
|
Some(inh.font_variation_settings.clone())
|
|
}
|
|
}
|
|
let is_table = tree
|
|
.get_node(id)
|
|
.map_or(false, |node| {
|
|
node.as_element()
|
|
.map_or(false, |name| name.local.as_ref() == "table")
|
|
});
|
|
if is_table && inh.legacy_center && style.text_align.is_none() {
|
|
// The vendor alignment used by <center> centers the table
|
|
// outer box but does not leak into its internal formatting
|
|
// context. Browser UA table layout resets it to start.
|
|
style.text_align = Some(taffy::AlignItems::FLEX_START);
|
|
style.legacy_center = false;
|
|
inh.text_align = style.text_align;
|
|
inh.legacy_center = false;
|
|
} else {
|
|
match style.text_align {
|
|
Some(a) => {
|
|
inh.text_align = Some(a);
|
|
inh.legacy_center = style.legacy_center;
|
|
}
|
|
None => {
|
|
style.text_align = inh.text_align;
|
|
style.legacy_center = inh.legacy_center;
|
|
}
|
|
}
|
|
}
|
|
inh.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden);
|
|
inh.opacity_product *= style.opacity.unwrap_or(1.0);
|
|
style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02;
|
|
match style.list_style { Some(v) => inh.list_style = v, None => style.list_style = Some(inh.list_style) }
|
|
match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) }
|
|
match style.text_wrap_style { Some(v) => inh.text_wrap_style = v, None => style.text_wrap_style = Some(inh.text_wrap_style) }
|
|
match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) }
|
|
match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) }
|
|
if style.box_sizing == crate::BoxSizing::Inherit {
|
|
style.box_sizing = inh.box_sizing;
|
|
}
|
|
inh.box_sizing = style.box_sizing;
|
|
match style.border_collapse {
|
|
Some(value) => inh.border_collapse = value,
|
|
None => style.border_collapse = Some(inh.border_collapse),
|
|
}
|
|
let is_table_part = tree.get_node(id).map_or(false, |node| {
|
|
node.as_element().map_or(false, |name| {
|
|
matches!(
|
|
name.local.as_ref(),
|
|
"tbody" | "thead" | "tfoot" | "tr" | "td" | "th"
|
|
)
|
|
})
|
|
});
|
|
if is_table_part {
|
|
match style.vertical_align {
|
|
Some(value) => inh.table_vertical_align = Some(value),
|
|
None => style.vertical_align = inh.table_vertical_align,
|
|
}
|
|
}
|
|
|
|
// Resolve font/viewport-relative box edges now that their
|
|
// reference sizes are known. Percentage margins still use the
|
|
// estimated containing-block width here. Pure percentage
|
|
// padding stays typed until Taffy knows the final flex/grid
|
|
// containing-block inline size; its used pixels are synced
|
|
// back into `LayoutStyle` after the final layout.
|
|
for i in 0..4 {
|
|
if let Some(expression) = style.padding_expressions[i].as_deref() {
|
|
if let Some(px) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
cb_w,
|
|
) {
|
|
match i {
|
|
0 => style.padding.top = px.max(0.0),
|
|
1 => style.padding.right = px.max(0.0),
|
|
2 => style.padding.bottom = px.max(0.0),
|
|
_ => style.padding.left = px.max(0.0),
|
|
}
|
|
}
|
|
}
|
|
if let Some(expression) = style.margin_expressions[i].as_deref() {
|
|
if let Some(px) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
em_px,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
cb_w,
|
|
) {
|
|
match i {
|
|
0 => style.margin.top = px,
|
|
1 => style.margin.right = px,
|
|
2 => style.margin.bottom = px,
|
|
_ => style.margin.left = px,
|
|
}
|
|
}
|
|
}
|
|
if let Some(relative) = style.padding_relative[i] {
|
|
if let crate::Dimension::Px(px) =
|
|
relative.resolve(em_px, root_fs, vw, vh)
|
|
{
|
|
match i {
|
|
0 => style.padding.top = px.max(0.0),
|
|
1 => style.padding.right = px.max(0.0),
|
|
2 => style.padding.bottom = px.max(0.0),
|
|
_ => style.padding.left = px.max(0.0),
|
|
}
|
|
}
|
|
}
|
|
if let Some(relative) = style.margin_relative[i] {
|
|
if let crate::Dimension::Px(px) =
|
|
relative.resolve(em_px, root_fs, vw, vh)
|
|
{
|
|
match i {
|
|
0 => style.margin.top = px,
|
|
1 => style.margin.right = px,
|
|
2 => style.margin.bottom = px,
|
|
_ => style.margin.left = px,
|
|
}
|
|
}
|
|
}
|
|
if let Some(frac) = style.margin_percent[i] {
|
|
let px = frac * cb_w;
|
|
match i {
|
|
0 => style.margin.top = px,
|
|
1 => style.margin.right = px,
|
|
2 => style.margin.bottom = px,
|
|
_ => style.margin.left = px,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pseudo-elements inherit the originating element's COMPUTED
|
|
// values. They were cascaded beside the host before this
|
|
// top-down pass, so inheriting its still-unresolved specified
|
|
// values there made `font-size:.875rem` disappear and left a
|
|
// positioned generated label at the 16px paint fallback.
|
|
// Resolve pseudo-authored relative values against the host,
|
|
// then fill every omitted inherited property from the host's
|
|
// now-final computed style.
|
|
let host_color = style.color;
|
|
let host_font_size = style.font_size.unwrap_or(parent_fs);
|
|
let host_letter_spacing = style.letter_spacing.unwrap_or(0.0);
|
|
let host_letter_spacing_non_normal =
|
|
style.letter_spacing_non_normal.unwrap_or(false);
|
|
let host_weight = crate::style::used_font_weight(style);
|
|
let host_family = style.font_family.clone();
|
|
let host_optical_sizing = style.font_optical_sizing;
|
|
let host_variation_settings = style.font_variation_settings.clone();
|
|
let host_line_height = style.line_height;
|
|
let host_text_wrap_style = style.text_wrap_style;
|
|
let host_transform = style.text_transform;
|
|
let host_italic = style.font_style_italic;
|
|
let host_text_align = style.text_align;
|
|
let host_invisible = style.effectively_invisible;
|
|
let settle_pseudo = |pseudo: &mut crate::LayoutStyle| {
|
|
if let Some(expression) = pseudo.font_size_expression.as_deref() {
|
|
pseudo.font_size = crate::style::resolve_contextual_length(
|
|
expression,
|
|
host_font_size,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
host_font_size,
|
|
);
|
|
} else if let Some(raw) = pseudo.font_size_raw {
|
|
pseudo.font_size = Some(match raw {
|
|
crate::Dimension::Percent(percent) => host_font_size * percent,
|
|
crate::Dimension::Em(value) => host_font_size * value,
|
|
dimension => match dimension.resolve(
|
|
host_font_size,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
) {
|
|
crate::Dimension::Px(pixels) => pixels,
|
|
_ => host_font_size,
|
|
},
|
|
});
|
|
} else if pseudo.font_size.is_none() {
|
|
pseudo.font_size = Some(host_font_size);
|
|
}
|
|
let pseudo_em = pseudo.font_size.unwrap_or(host_font_size);
|
|
if let Some(expression) = pseudo.letter_spacing_expression.as_deref() {
|
|
pseudo.letter_spacing = crate::style::resolve_contextual_length(
|
|
expression,
|
|
pseudo_em,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
pseudo_em,
|
|
);
|
|
} else if let Some(raw) = pseudo.letter_spacing_raw {
|
|
pseudo.letter_spacing =
|
|
match raw.resolve(pseudo_em, root_fs, vw, vh) {
|
|
crate::Dimension::Px(pixels) if pixels.is_finite() => {
|
|
Some(pixels)
|
|
}
|
|
_ => None,
|
|
};
|
|
} else if pseudo.letter_spacing.is_none() {
|
|
pseudo.letter_spacing = Some(host_letter_spacing);
|
|
}
|
|
if pseudo.letter_spacing_non_normal.is_none() {
|
|
pseudo.letter_spacing_non_normal =
|
|
Some(host_letter_spacing_non_normal);
|
|
}
|
|
for index in 0..6 {
|
|
if let Some(expression) = pseudo.size_expressions[index].as_deref() {
|
|
let percent_base = if matches!(index, 1 | 3 | 5) {
|
|
viewport.1
|
|
} else {
|
|
cb_w
|
|
};
|
|
if let Some(px) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
pseudo_em,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
percent_base,
|
|
) {
|
|
let resolved = crate::Dimension::Px(px);
|
|
match index {
|
|
0 => pseudo.width = resolved,
|
|
1 => pseudo.height = resolved,
|
|
2 => pseudo.min_width = resolved,
|
|
3 => pseudo.min_height = resolved,
|
|
4 => pseudo.max_width = resolved,
|
|
_ => pseudo.max_height = resolved,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pseudo.width = pseudo.width.resolve(pseudo_em, root_fs, vw, vh);
|
|
pseudo.height = pseudo.height.resolve(pseudo_em, root_fs, vw, vh);
|
|
pseudo.min_width = pseudo.min_width.resolve(pseudo_em, root_fs, vw, vh);
|
|
pseudo.min_height = pseudo.min_height.resolve(pseudo_em, root_fs, vw, vh);
|
|
pseudo.max_width = pseudo.max_width.resolve(pseudo_em, root_fs, vw, vh);
|
|
pseudo.max_height = pseudo.max_height.resolve(pseudo_em, root_fs, vw, vh);
|
|
for index in 0..4 {
|
|
if let Some(relative) = pseudo.padding_relative[index] {
|
|
if let crate::Dimension::Px(px) =
|
|
relative.resolve(pseudo_em, root_fs, vw, vh)
|
|
{
|
|
match index {
|
|
0 => pseudo.padding.top = px.max(0.0),
|
|
1 => pseudo.padding.right = px.max(0.0),
|
|
2 => pseudo.padding.bottom = px.max(0.0),
|
|
_ => pseudo.padding.left = px.max(0.0),
|
|
}
|
|
}
|
|
}
|
|
if let Some(relative) = pseudo.margin_relative[index] {
|
|
if let crate::Dimension::Px(px) =
|
|
relative.resolve(pseudo_em, root_fs, vw, vh)
|
|
{
|
|
match index {
|
|
0 => pseudo.margin.top = px,
|
|
1 => pseudo.margin.right = px,
|
|
2 => pseudo.margin.bottom = px,
|
|
_ => pseudo.margin.left = px,
|
|
}
|
|
}
|
|
}
|
|
if let Some(percent) = pseudo.margin_percent[index] {
|
|
let px = percent * cb_w;
|
|
match index {
|
|
0 => pseudo.margin.top = px,
|
|
1 => pseudo.margin.right = px,
|
|
2 => pseudo.margin.bottom = px,
|
|
_ => pseudo.margin.left = px,
|
|
}
|
|
}
|
|
if let Some(inset) = pseudo.inset[index] {
|
|
pseudo.inset[index] = Some(inset.resolve(pseudo_em, root_fs, vw, vh));
|
|
}
|
|
}
|
|
let weight = crate::style::computed_font_weight(
|
|
pseudo.font_weight.as_deref(),
|
|
host_weight,
|
|
);
|
|
pseudo.font_weight = Some(weight.to_string());
|
|
if pseudo.font_family.is_none() {
|
|
pseudo.font_family = host_family.clone();
|
|
}
|
|
if pseudo.font_optical_sizing.is_none() {
|
|
pseudo.font_optical_sizing = host_optical_sizing;
|
|
}
|
|
if pseudo.font_variation_settings.is_none() {
|
|
pseudo.font_variation_settings =
|
|
host_variation_settings.clone();
|
|
}
|
|
if let Some(expression) = pseudo.line_height_expression.as_deref() {
|
|
if let Some(resolved) = crate::style::resolve_contextual_length(
|
|
expression,
|
|
pseudo_em,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
pseudo_em,
|
|
) {
|
|
pseudo.line_height = Some(
|
|
if crate::style::line_height_expression_is_length(expression) {
|
|
crate::LineHeight::Px(resolved)
|
|
} else {
|
|
crate::LineHeight::Ratio(resolved)
|
|
},
|
|
);
|
|
}
|
|
} else if let Some(crate::LineHeight::Relative(relative)) =
|
|
pseudo.line_height
|
|
{
|
|
let pixels = match relative {
|
|
crate::Dimension::Percent(percent) => pseudo_em * percent,
|
|
dimension => match dimension.resolve(
|
|
pseudo_em,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
) {
|
|
crate::Dimension::Px(pixels) => pixels,
|
|
_ => pseudo_em,
|
|
},
|
|
};
|
|
pseudo.line_height = Some(crate::LineHeight::Px(pixels));
|
|
} else if pseudo.line_height.is_none() {
|
|
pseudo.line_height = host_line_height;
|
|
}
|
|
if pseudo.text_wrap_style.is_none() {
|
|
pseudo.text_wrap_style = host_text_wrap_style;
|
|
}
|
|
if pseudo.color.is_none() {
|
|
pseudo.color = host_color;
|
|
}
|
|
if pseudo.text_transform.is_none() {
|
|
pseudo.text_transform = host_transform;
|
|
}
|
|
if pseudo.font_style_italic.is_none() {
|
|
pseudo.font_style_italic = host_italic;
|
|
}
|
|
if pseudo.text_align.is_none() {
|
|
pseudo.text_align = host_text_align;
|
|
}
|
|
pseudo.effectively_invisible = host_invisible;
|
|
};
|
|
if let Some(pseudo) = style.before_pseudo.as_deref_mut() {
|
|
settle_pseudo(pseudo);
|
|
}
|
|
if let Some(pseudo) = style.after_pseudo.as_deref_mut() {
|
|
settle_pseudo(pseudo);
|
|
}
|
|
|
|
// Containing-block width handed to this element's children is
|
|
// its own content-box width. A definite content-box width is
|
|
// already that value; a border-box or auto width includes
|
|
// padding and border, which must be removed.
|
|
let used_w = match style.width {
|
|
crate::Dimension::Px(w) => w,
|
|
crate::Dimension::Percent(p) => p * cb_w,
|
|
_ => (cb_w - style.margin.left - style.margin.right).max(0.0),
|
|
};
|
|
let definite_content_box = matches!(
|
|
style.width,
|
|
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
|
|
) && style.box_sizing == crate::BoxSizing::ContentBox;
|
|
child_cb_width = if definite_content_box {
|
|
used_w.max(0.0)
|
|
} else {
|
|
(used_w
|
|
- style.padding.left
|
|
- style.padding.right
|
|
- style.border.left
|
|
- style.border.right)
|
|
.max(0.0)
|
|
};
|
|
}
|
|
inh.cb_width = child_cb_width;
|
|
inh.cb_height_definite = child_cb_height_definite;
|
|
for cid in tree.children(id).into_iter().rev() {
|
|
queue.push((cid, inh.clone()));
|
|
}
|
|
}
|
|
|
|
// Border-collapse is inherited, so only distribute a table's
|
|
// effective spacing to the legacy flex fallback after the computed
|
|
// top-down values are known.
|
|
propagate_border_spacing(tree, &mut styles);
|
|
|
|
// Resolve native form-control intrinsic border-box geometry after
|
|
// inheritance and author cascading. Text-like inputs use the HTML
|
|
// `size` attribute (20 by default) and the control's own computed
|
|
// font; author CSS widths/heights remain authoritative. Without this
|
|
// replaced-control sizing, inputs/selects are empty auto-sized leaves
|
|
// (0px tall and often stretched to their container).
|
|
for (&id, style) in styles.iter_mut() {
|
|
let Some(node) = tree.get_node(id) else {
|
|
continue;
|
|
};
|
|
let Some(element) = node.as_element() else {
|
|
continue;
|
|
};
|
|
if element.local.as_ref() == "button" && style.width == crate::Dimension::Auto {
|
|
// Buttons remain intrinsically sized form controls even when
|
|
// author CSS changes their inner display to flex/grid. Treating
|
|
// `display:flex` as an ordinary block makes an auto-width
|
|
// button stretch to its containing block (MDN's compact search
|
|
// pill became 637px wide). Approximate the native max-content
|
|
// border box from its label, generated icon, gap, and edges.
|
|
let font_size = style.font_size.unwrap_or(13.333_333).max(1.0);
|
|
let bold = crate::style::used_font_weight(style) >= 600;
|
|
let label = tree
|
|
.text_content(id)
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let mut content_width =
|
|
text_width(
|
|
&label,
|
|
font_size,
|
|
bold,
|
|
style.font_family.as_deref(),
|
|
style.letter_spacing.unwrap_or(0.0),
|
|
);
|
|
let pseudo_width = |pseudo: Option<&crate::LayoutStyle>| {
|
|
let Some(pseudo) = pseudo else { return 0.0 };
|
|
match pseudo.width {
|
|
crate::Dimension::Px(px) => px.max(0.0),
|
|
crate::Dimension::Em(value) | crate::Dimension::Rem(value) => {
|
|
(value * font_size).max(0.0)
|
|
}
|
|
crate::Dimension::Percent(percent) => (percent * font_size).max(0.0),
|
|
_ if pseudo.mask_image.is_some() || pseudo.background_image.is_some() => {
|
|
font_size
|
|
}
|
|
_ => 0.0,
|
|
}
|
|
};
|
|
let before = pseudo_width(style.before_pseudo.as_deref());
|
|
let after = pseudo_width(style.after_pseudo.as_deref());
|
|
let extra_parts = usize::from(before > 0.0) + usize::from(after > 0.0);
|
|
content_width += before + after;
|
|
if extra_parts > 0 && !label.is_empty() {
|
|
content_width += style.column_gap.unwrap_or(0.0) * extra_parts as f32;
|
|
}
|
|
let horizontal_edges = style.padding.left
|
|
+ style.padding.right
|
|
+ style.border.left
|
|
+ style.border.right;
|
|
style.width =
|
|
crate::Dimension::Px(if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
content_width
|
|
} else {
|
|
content_width + horizontal_edges
|
|
});
|
|
}
|
|
if element.local.as_ref() == "select" {
|
|
let option_labels: Vec<String> = tree
|
|
.descendants(id)
|
|
.into_iter()
|
|
.filter(|option_id| {
|
|
tree.get_node(*option_id)
|
|
.map_or(false, |option| {
|
|
option
|
|
.as_element()
|
|
.map_or(false, |name| name.local.as_ref() == "option")
|
|
})
|
|
})
|
|
.map(|option_id| tree.text_content(option_id).trim().to_string())
|
|
.collect();
|
|
let font_size = style.font_size.unwrap_or(13.333_333).max(1.0);
|
|
let bold = crate::style::used_font_weight(style) >= 600;
|
|
let label_width = option_labels
|
|
.iter()
|
|
.map(|label| {
|
|
text_width(
|
|
label,
|
|
font_size,
|
|
bold,
|
|
style.font_family.as_deref(),
|
|
style.letter_spacing.unwrap_or(0.0),
|
|
)
|
|
})
|
|
.fold(0.0f32, f32::max);
|
|
let horizontal_edges = style.padding.left
|
|
+ style.padding.right
|
|
+ style.border.left
|
|
+ style.border.right;
|
|
let vertical_edges = style.padding.top
|
|
+ style.padding.bottom
|
|
+ style.border.top
|
|
+ style.border.bottom;
|
|
let rows = node
|
|
.get_attribute("size")
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.filter(|&value| value > 1)
|
|
.unwrap_or(1) as f32;
|
|
let intrinsic_width = label_width + horizontal_edges;
|
|
let intrinsic_height =
|
|
crate::inline::used_line_height(style).max(1.0) * rows
|
|
+ vertical_edges;
|
|
if style.width == crate::Dimension::Auto {
|
|
style.width = crate::Dimension::Px(
|
|
if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
label_width
|
|
} else {
|
|
intrinsic_width
|
|
},
|
|
);
|
|
}
|
|
if style.height == crate::Dimension::Auto {
|
|
style.height = crate::Dimension::Px(
|
|
if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
(intrinsic_height - vertical_edges).max(0.0)
|
|
} else {
|
|
intrinsic_height
|
|
},
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if element.local.as_ref() != "input" {
|
|
continue;
|
|
}
|
|
let input_type = node
|
|
.get_attribute("type")
|
|
.unwrap_or("text")
|
|
.trim()
|
|
.to_ascii_lowercase();
|
|
if input_type == "hidden" {
|
|
style.display = crate::Display::None;
|
|
continue;
|
|
}
|
|
|
|
let font_size = style.font_size.unwrap_or(13.333_333).max(1.0);
|
|
let horizontal_edges = style.padding.left
|
|
+ style.padding.right
|
|
+ style.border.left
|
|
+ style.border.right;
|
|
let vertical_edges = style.padding.top
|
|
+ style.padding.bottom
|
|
+ style.border.top
|
|
+ style.border.bottom;
|
|
let default_height =
|
|
crate::inline::used_line_height(style).max(1.0) + vertical_edges;
|
|
|
|
let (intrinsic_width, intrinsic_height) = match input_type.as_str() {
|
|
"checkbox" | "radio" => (13.0, 13.0),
|
|
"range" => (129.0, 16.0),
|
|
"color" => (50.0, 27.0),
|
|
"file" => (253.0, default_height.max(22.0)),
|
|
"submit" | "reset" | "button" => {
|
|
let fallback = match input_type.as_str() {
|
|
"reset" => "Reset",
|
|
"button" => "",
|
|
_ => "Submit Query",
|
|
};
|
|
let label = node.get_attribute("value").unwrap_or(fallback);
|
|
(
|
|
(label.chars().count() as f32 * font_size * 0.55
|
|
+ font_size * 1.5
|
|
+ horizontal_edges)
|
|
.max(20.0),
|
|
default_height,
|
|
)
|
|
}
|
|
"image" => (horizontal_edges, vertical_edges),
|
|
_ => {
|
|
let size = node
|
|
.get_attribute("size")
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
.filter(|&value| value > 0)
|
|
.unwrap_or(20) as f32;
|
|
(
|
|
size * font_size * 0.6
|
|
+ font_size * 0.675
|
|
+ horizontal_edges,
|
|
default_height,
|
|
)
|
|
}
|
|
};
|
|
if style.width == crate::Dimension::Auto {
|
|
let declared_width = if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
(intrinsic_width - horizontal_edges).max(0.0)
|
|
} else {
|
|
intrinsic_width
|
|
};
|
|
style.width = crate::Dimension::Px(declared_width);
|
|
}
|
|
if style.height == crate::Dimension::Auto {
|
|
let declared_height = if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
(intrinsic_height - vertical_edges).max(0.0)
|
|
} else {
|
|
intrinsic_height
|
|
};
|
|
style.height = crate::Dimension::Px(declared_height);
|
|
}
|
|
}
|
|
|
|
resolve_grid_areas(tree, root_id, &mut styles);
|
|
|
|
// The root's outer display is always blockified. Keep a declared
|
|
// inline-flex/grid root's inner mode, but remove its inline
|
|
// participation marker before box construction.
|
|
if let Some(root_style) = styles.get_mut(&root_id) {
|
|
crate::blockify_outer_display(root_style);
|
|
}
|
|
|
|
// CSS Display blockification changes only the outer display. Preserve
|
|
// the inner Flex/Grid mode of inline-flex/grid items.
|
|
let item_parents: Vec<NodeId> = styles
|
|
.iter()
|
|
.filter(|(_, s)| {
|
|
s.display == crate::Display::Grid
|
|
|| (s.display == crate::Display::Flex
|
|
&& !s.internal_flex_container)
|
|
})
|
|
.map(|(&id, _)| id)
|
|
.collect();
|
|
for pid in item_parents {
|
|
blockify_layout_children(tree, pid, &mut styles);
|
|
}
|
|
|
|
// In an auto-height column flex container, a zero flex basis still
|
|
// participates in intrinsic main-size calculation through the item's
|
|
// automatic minimum size. Taffy otherwise treats `flex: 1 1` plus
|
|
// overflow:auto as an unconditional zero contribution, collapsing the
|
|
// entire ancestor chain even when the item contains many lines. Use
|
|
// an auto basis for this indefinite-size phase; definite-height flex
|
|
// containers keep the authored zero basis and normal free-space math.
|
|
let intrinsic_column_flex_parents: Vec<NodeId> = styles
|
|
.iter()
|
|
.filter(|(_, style)| {
|
|
style.display == crate::Display::Flex
|
|
&& style.flex_direction == Some(taffy::FlexDirection::Column)
|
|
&& style.height == crate::Dimension::Auto
|
|
})
|
|
.map(|(&id, _)| id)
|
|
.collect();
|
|
for parent in intrinsic_column_flex_parents {
|
|
for child in tree.children(parent) {
|
|
let Some(style) = styles.get_mut(&child) else {
|
|
continue;
|
|
};
|
|
let zero_basis = matches!(
|
|
style.flex_basis,
|
|
crate::Dimension::Px(value) | crate::Dimension::Percent(value)
|
|
if value == 0.0
|
|
);
|
|
if zero_basis
|
|
&& style.height == crate::Dimension::Auto
|
|
&& style.min_height == crate::Dimension::Auto
|
|
{
|
|
style.flex_basis = crate::Dimension::Auto;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Absolute/fixed boxes and floats are blockified externally but retain
|
|
// their inner formatting mode and their context-specific shrink-fit.
|
|
for style in styles.values_mut() {
|
|
if matches!(style.position, Some(taffy::Position::Absolute)) || style.float.is_some() {
|
|
crate::blockify_outer_display(style);
|
|
}
|
|
}
|
|
|
|
// The initial containing block is modelled by the root taffy node's
|
|
// definite viewport height (set at build), which is what `html,body{
|
|
// height:100%}` chains and sticky-footer app shells resolve against.
|
|
// taffy 0.7 needed an extra `min-height: viewport` push on the root
|
|
// style to stop those chains collapsing to content height; taffy 0.12
|
|
// resolves them from the node's definite height directly, and that push
|
|
// instead over-propagated the viewport height down every `height:100%`
|
|
// subtree (nextjs.org's nav stretched to a full 688px, burying the
|
|
// hero), so it is gone.
|
|
|
|
// Apply fetched intrinsic image sizes. A replaced element with no
|
|
// explicit dimensions must size from its intrinsic pixels (else it is
|
|
// 0x0 and never paints); with one dimension given, the aspect ratio
|
|
// fills the other. `max-width:100%` (a UA default for img) still caps
|
|
// it to the container, and the aspect ratio keeps it proportional.
|
|
for (&nid, &(iw, ih)) in intrinsic {
|
|
if iw <= 0.0 || ih <= 0.0 {
|
|
continue;
|
|
}
|
|
if let Some(s) = styles.get_mut(&nid) {
|
|
s.intrinsic_size = Some((iw, ih));
|
|
if s.aspect_ratio.is_none() || s.aspect_ratio_is_mapped {
|
|
s.aspect_ratio = Some(iw / ih);
|
|
s.aspect_ratio_is_mapped = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
let deferred_cyclic_inline_sizes =
|
|
defer_cyclic_flex_inline_sizes(tree, &mut styles, root_fs, vw, vh);
|
|
|
|
if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &mut words, &mut engine, &mut ifc_items, &styles) {
|
|
// Taffy has no outer display type and only gives an auto-width
|
|
// Block root the initial-containing-block width. CSS blockifies
|
|
// Flex/Grid roots too, so supply the equivalent used width while
|
|
// leaving an authored root width untouched.
|
|
if let Some(root_style) = styles.get(&root_id) {
|
|
if root_style.width == crate::Dimension::Auto
|
|
&& matches!(root_style.display, crate::Display::Flex | crate::Display::Grid)
|
|
{
|
|
if let Ok(current) = taffy_tree.style(taffy_root) {
|
|
let mut adjusted = current.clone();
|
|
let outer = (initial_cb_width
|
|
- root_style.margin.left
|
|
- root_style.margin.right)
|
|
.max(0.0);
|
|
let declared = if root_style.box_sizing == crate::BoxSizing::ContentBox {
|
|
(outer
|
|
- root_style.padding.left
|
|
- root_style.padding.right
|
|
- root_style.border.left
|
|
- root_style.border.right)
|
|
.max(0.0)
|
|
} else {
|
|
outer
|
|
};
|
|
adjusted.size.width = taffy::Dimension::length(declared);
|
|
let _ = taffy_tree.set_style(taffy_root, adjusted);
|
|
}
|
|
}
|
|
}
|
|
let static_position_candidates =
|
|
reparent_inset_positioned_nodes(tree, &mut taffy_tree, taffy_root, &id_map, &styles);
|
|
let available = taffy::Size {
|
|
width: taffy::AvailableSpace::Definite(initial_cb_width),
|
|
height: taffy::AvailableSpace::Definite(viewport.1),
|
|
};
|
|
#[cfg(feature = "paint")]
|
|
{
|
|
let engine = &mut engine;
|
|
let mut measure =
|
|
|known: taffy::Size<Option<f32>>, avail: taffy::Size<taffy::AvailableSpace>, _node, ctx: Option<&mut usize>, _style: &taffy::Style| {
|
|
match ctx {
|
|
Some(&mut idx) => {
|
|
engine.measure_taffy(idx, known, avail)
|
|
}
|
|
None => taffy::Size::ZERO,
|
|
}
|
|
};
|
|
|
|
// Table used-width pass. A grid table is built at width:auto, so
|
|
// its final width has to be chosen the way CSS chooses a table's
|
|
// used width: at least the content min-content (so an unbreakable
|
|
// wide cell, e.g. a fixed-width image row in an infobox, makes the
|
|
// table grow rather than overflow), at most the available width,
|
|
// preferring the author's specified width when there is one.
|
|
let mut tables: Vec<(taffy::NodeId, NodeId, usize)> = id_map
|
|
.iter()
|
|
.filter(|(_, dom)| {
|
|
tree.get_node(**dom)
|
|
.and_then(|n| n.as_element().map(|e| e.local.as_ref() == "table"))
|
|
.unwrap_or(false)
|
|
})
|
|
.map(|(t, d)| (*t, *d, dom_depth(tree, *d)))
|
|
.collect();
|
|
// Deepest table first: an inner table gets its final width set
|
|
// before an outer table measures the subtree that contains it.
|
|
// Two tables at the same depth cannot be nested in one another
|
|
// (nesting increases depth), so their relative order is
|
|
// irrelevant and the final layout is deterministic even though
|
|
// id_map iteration order is not.
|
|
tables.sort_by(|a, b| b.2.cmp(&a.2));
|
|
for (tnode, dom, _depth) in tables {
|
|
let Some(table_style) = styles.get(&dom) else {
|
|
continue;
|
|
};
|
|
// A percentage-width table resolves against its container, so
|
|
// leave taffy's percentage handling in place.
|
|
let width_style = Some(table_style.width);
|
|
if matches!(width_style, Some(crate::Dimension::Percent(_))) {
|
|
continue;
|
|
}
|
|
let min_c = {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
tnode,
|
|
taffy::Size { width: taffy::AvailableSpace::MinContent, height: taffy::AvailableSpace::MaxContent },
|
|
&mut measure,
|
|
);
|
|
taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0)
|
|
};
|
|
// A table can never be narrower than an unshrinkable
|
|
// fixed-width descendant. A Wikipedia infobox holds a
|
|
// ~267px image montage and a ~250px geologic-timeline
|
|
// widget, each with an explicit px width; taffy's grid
|
|
// min-content does not surface such a deep descendant's
|
|
// definite width up through the intervening flex/absolute
|
|
// boxes, leaving the table too narrow so those widgets
|
|
// overflow it. Floor min-content by the widest fixed child,
|
|
// matching CSS (a table's min-content is at least any
|
|
// fixed-width content it contains).
|
|
let min_c = min_c.max(max_definite_descendant_width(tree, dom, &styles).unwrap_or(0.0));
|
|
let max_c = {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
tnode,
|
|
taffy::Size { width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent },
|
|
&mut measure,
|
|
);
|
|
taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0)
|
|
};
|
|
let inline_outer_edges = table_inline_outer_edges(table_style);
|
|
let preferred_outer = match width_style {
|
|
Some(crate::Dimension::Px(w))
|
|
if table_style.box_sizing == crate::BoxSizing::ContentBox =>
|
|
{
|
|
w + inline_outer_edges
|
|
}
|
|
Some(crate::Dimension::Px(w)) => w,
|
|
_ => max_c,
|
|
};
|
|
let used_outer = preferred_outer.max(min_c).min(initial_cb_width.max(min_c));
|
|
let used_declaration = if table_style.box_sizing == crate::BoxSizing::ContentBox
|
|
{
|
|
(used_outer - inline_outer_edges).max(0.0)
|
|
} else {
|
|
used_outer
|
|
};
|
|
// Distribute the used track space proportionally between
|
|
// each column's own min-content and max-content width, the
|
|
// way CSS tables do, instead of letting the grid hand every
|
|
// auto track an equal share of the surplus (which over-widens
|
|
// narrow label columns and starves wide prose columns).
|
|
let cells: Vec<(taffy::NodeId, usize, usize)> = taffy_tree
|
|
.children(tnode)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|cell| {
|
|
let gc = taffy_tree.style(cell).ok()?.grid_column.clone();
|
|
let col = match gc.start {
|
|
GridPlacement::Line(l) => (l.as_i16().max(1) as usize) - 1,
|
|
_ => return None,
|
|
};
|
|
let span = match gc.end {
|
|
GridPlacement::Span(s) => (s as usize).max(1),
|
|
_ => 1,
|
|
};
|
|
Some((cell, col, span))
|
|
})
|
|
.collect();
|
|
let ncols = taffy_tree
|
|
.style(tnode)
|
|
.map(|s| s.grid_template_columns.len())
|
|
.unwrap_or(0);
|
|
// Bound the extra per-cell measurement work; a pathologically
|
|
// large table keeps the grid's equal-share sizing (still renders).
|
|
if ncols > 0 && cells.len() <= 4096 {
|
|
// Measure every cell's own min-content and max-content width
|
|
// in isolation (the cell node is a valid subtree root; its
|
|
// grid placement only matters to its parent, so it lays out
|
|
// as a plain box here).
|
|
let mut measured: Vec<(usize, usize, f32, f32)> =
|
|
Vec::with_capacity(cells.len());
|
|
for (cell, col, span) in &cells {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
*cell,
|
|
taffy::Size { width: taffy::AvailableSpace::MinContent, height: taffy::AvailableSpace::MaxContent },
|
|
&mut measure,
|
|
);
|
|
let cmin = taffy_tree.layout(*cell).map(|l| l.size.width).unwrap_or(0.0);
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
*cell,
|
|
taffy::Size { width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent },
|
|
&mut measure,
|
|
);
|
|
let cmax = taffy_tree.layout(*cell).map(|l| l.size.width).unwrap_or(0.0);
|
|
measured.push((*col, *span, cmin, cmax.max(cmin)));
|
|
}
|
|
let mut col_min = vec![0.0f32; ncols];
|
|
let mut col_max = vec![0.0f32; ncols];
|
|
// Pass 1: single-column cells set each column's floor.
|
|
for &(col, span, cmin, cmax) in &measured {
|
|
if span == 1 && col < ncols {
|
|
col_min[col] = col_min[col].max(cmin);
|
|
col_max[col] = col_max[col].max(cmax);
|
|
}
|
|
}
|
|
// Pass 2: a spanning cell that needs more than its columns
|
|
// currently give grows them, splitting the shortfall evenly
|
|
// across the spanned columns (keeps colspan lining up).
|
|
for &(col, span, cmin, cmax) in &measured {
|
|
if span <= 1 || col >= ncols {
|
|
continue;
|
|
}
|
|
let end = (col + span).min(ncols);
|
|
let n = (end - col) as f32;
|
|
let cur_min: f32 = col_min[col..end].iter().sum();
|
|
if cmin > cur_min {
|
|
let add = (cmin - cur_min) / n;
|
|
for w in &mut col_min[col..end] {
|
|
*w += add;
|
|
}
|
|
}
|
|
let cur_max: f32 = col_max[col..end].iter().sum();
|
|
if cmax > cur_max {
|
|
let add = (cmax - cur_max) / n;
|
|
for w in &mut col_max[col..end] {
|
|
*w += add;
|
|
}
|
|
}
|
|
}
|
|
for j in 0..ncols {
|
|
if col_max[j] < col_min[j] {
|
|
col_max[j] = col_min[j];
|
|
}
|
|
}
|
|
// Track space is the final table border box minus its
|
|
// actual border/padding, the two outer spacing bands,
|
|
// and the spacing between columns. Inferring this from
|
|
// min-content is wrong when a specified cell/column
|
|
// width already inflated that measurement: the fixed
|
|
// width gets counted as "overhead", starving later
|
|
// auto columns and forcing avoidable text wrapping.
|
|
let (horizontal_spacing, _) = table_spacing(table_style);
|
|
let interior_spacing =
|
|
horizontal_spacing * ncols.saturating_sub(1) as f32;
|
|
let target =
|
|
(used_outer - inline_outer_edges - interior_spacing)
|
|
.max(0.0);
|
|
// Specified column widths (a `<col>` or colspan-1 cell
|
|
// width, recorded at build time) pin their columns:
|
|
// px directly, percent against the table's content
|
|
// width. Content min-content still floors them, so a
|
|
// too-narrow spec never crushes its content. The
|
|
// remaining space interpolates across the auto
|
|
// columns exactly as before.
|
|
let specified_columns = ifc_items.table_cols.get(&tnode);
|
|
if let Some((spec_px, spec_pct)) = specified_columns {
|
|
for j in 0..ncols {
|
|
let spec = spec_px
|
|
.get(j)
|
|
.copied()
|
|
.flatten()
|
|
.or_else(|| spec_pct.get(j).copied().flatten().map(|p| p * target));
|
|
if let Some(w) = spec {
|
|
let w = w.max(col_min[j]);
|
|
col_min[j] = w;
|
|
col_max[j] = w;
|
|
}
|
|
}
|
|
}
|
|
let sum_min: f32 = col_min.iter().sum();
|
|
let sum_max: f32 = col_max.iter().sum();
|
|
let widths: Vec<f32> = if target <= sum_min {
|
|
col_min.clone()
|
|
} else if target >= sum_max {
|
|
// Surplus follows the table-layout priority: auto
|
|
// columns absorb it before fixed and percentage
|
|
// columns. This is important even when every
|
|
// column has min==max (a one-word auto cell still
|
|
// fills the remainder of a definite-width table).
|
|
let mut result = col_max.clone();
|
|
let extra = target - sum_max;
|
|
let is_px = |j: usize| {
|
|
specified_columns
|
|
.and_then(|(px, _)| px.get(j))
|
|
.copied()
|
|
.flatten()
|
|
.is_some()
|
|
};
|
|
let is_pct = |j: usize| {
|
|
specified_columns
|
|
.and_then(|(_, pct)| pct.get(j))
|
|
.copied()
|
|
.flatten()
|
|
.is_some()
|
|
};
|
|
let mut candidates: Vec<usize> = (0..ncols)
|
|
.filter(|&j| !is_px(j) && !is_pct(j) && col_max[j] > 0.0)
|
|
.collect();
|
|
if candidates.is_empty() {
|
|
candidates = (0..ncols)
|
|
.filter(|&j| !is_px(j) && !is_pct(j))
|
|
.collect();
|
|
}
|
|
if candidates.is_empty() {
|
|
candidates = (0..ncols).filter(|&j| is_px(j)).collect();
|
|
}
|
|
if candidates.is_empty() {
|
|
candidates = (0..ncols).filter(|&j| is_pct(j)).collect();
|
|
}
|
|
if candidates.is_empty() {
|
|
candidates = (0..ncols).collect();
|
|
}
|
|
let weight_sum: f32 =
|
|
candidates.iter().map(|&j| col_max[j]).sum();
|
|
for &j in &candidates {
|
|
let share = if weight_sum > 0.0 {
|
|
extra * col_max[j] / weight_sum
|
|
} else {
|
|
extra / candidates.len() as f32
|
|
};
|
|
result[j] += share;
|
|
}
|
|
result
|
|
} else {
|
|
let scale = (target - sum_min) / (sum_max - sum_min);
|
|
col_min
|
|
.iter()
|
|
.zip(&col_max)
|
|
.map(|(mn, mx)| mn + (mx - mn) * scale)
|
|
.collect()
|
|
};
|
|
if let Ok(cur) = taffy_tree.style(tnode) {
|
|
let mut s = cur.clone();
|
|
s.size.width = length(used_declaration);
|
|
s.grid_template_columns = widths
|
|
.iter()
|
|
.map(|w| {
|
|
taffy::GridTemplateComponent::Single(taffy::MinMax {
|
|
min: taffy::MinTrackSizingFunction::length(*w),
|
|
max: taffy::MaxTrackSizingFunction::length(*w),
|
|
})
|
|
})
|
|
.collect();
|
|
let _ = taffy_tree.set_style(tnode, s);
|
|
}
|
|
} else if let Ok(cur) = taffy_tree.style(tnode) {
|
|
let mut s = cur.clone();
|
|
s.size.width = length(used_declaration);
|
|
let _ = taffy_tree.set_style(tnode, s);
|
|
}
|
|
}
|
|
|
|
if !static_position_candidates.is_empty() {
|
|
let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure);
|
|
resolve_static_positions_and_reparent(
|
|
&mut taffy_tree,
|
|
&static_position_candidates,
|
|
);
|
|
}
|
|
let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure);
|
|
if repair_intrinsic_column_flex_negative_margins(
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if resolve_deferred_flex_inline_sizes(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&mut styles,
|
|
&deferred_cyclic_inline_sizes,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if apply_float_continuations(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
&ifc_items,
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if apply_table_row_geometry(
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
&ifc_items,
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if apply_full_span_column_subgrids(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
|tree, node| {
|
|
tree.compute_layout_with_measure(
|
|
node,
|
|
taffy::Size {
|
|
width: taffy::AvailableSpace::MaxContent,
|
|
height: taffy::AvailableSpace::MaxContent,
|
|
},
|
|
&mut measure,
|
|
)
|
|
.ok()?;
|
|
tree.layout(node).ok().map(|layout| layout.size.width)
|
|
},
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
if apply_table_cell_block_alignment(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
) {
|
|
let _ = taffy_tree.compute_layout_with_measure(
|
|
taffy_root,
|
|
available,
|
|
&mut measure,
|
|
);
|
|
}
|
|
}
|
|
#[cfg(not(feature = "paint"))]
|
|
{
|
|
if !static_position_candidates.is_empty() {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
resolve_static_positions_and_reparent(
|
|
&mut taffy_tree,
|
|
&static_position_candidates,
|
|
);
|
|
}
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
if repair_intrinsic_column_flex_negative_margins(
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if resolve_deferred_flex_inline_sizes(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&mut styles,
|
|
&deferred_cyclic_inline_sizes,
|
|
root_fs,
|
|
vw,
|
|
vh,
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if apply_float_continuations(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
&ifc_items,
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if apply_table_row_geometry(
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
&ifc_items,
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if apply_full_span_column_subgrids(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
|tree, node| {
|
|
tree.compute_layout(
|
|
node,
|
|
taffy::Size {
|
|
width: taffy::AvailableSpace::MaxContent,
|
|
height: taffy::AvailableSpace::MaxContent,
|
|
},
|
|
)
|
|
.ok()?;
|
|
tree.layout(node).ok().map(|layout| layout.size.width)
|
|
},
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
if apply_table_cell_block_alignment(
|
|
tree,
|
|
&mut taffy_tree,
|
|
&id_map,
|
|
&styles,
|
|
) {
|
|
let _ = taffy_tree.compute_layout(taffy_root, available);
|
|
}
|
|
}
|
|
sync_resolved_percentage_padding(
|
|
&taffy_tree,
|
|
taffy_root,
|
|
initial_cb_width,
|
|
&id_map,
|
|
&ifc_items.generated,
|
|
&mut styles,
|
|
);
|
|
let generated_nodes: HashMap<taffy::NodeId, usize> = ifc_items
|
|
.generated
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, generated)| (generated.node, index))
|
|
.collect();
|
|
generated_rects.resize(ifc_items.generated.len(), None);
|
|
compute_absolute_rects(
|
|
&taffy_tree,
|
|
taffy_root,
|
|
initial_cb_x,
|
|
0.0,
|
|
&id_map,
|
|
&words,
|
|
&mut rects,
|
|
&mut text_runs,
|
|
&mut anon_rects,
|
|
&generated_nodes,
|
|
&mut generated_rects,
|
|
);
|
|
synthesize_row_rects(tree, &mut rects);
|
|
}
|
|
}
|
|
sync_positioned_pseudo_percentage_padding(&rects, &mut styles);
|
|
|
|
let mut clip_rects = HashMap::new();
|
|
let mut translates = HashMap::new();
|
|
if let Some(root_id) = root {
|
|
resolve_clip_rects(tree, root_id, None, 0.0, 0.0, &rects, &styles, &mut clip_rects, &mut translates);
|
|
}
|
|
|
|
// Pin each shaped inline context to its final content-box origin/width now
|
|
// that layout is done, so paint draws the same line breaks it was sized for.
|
|
#[cfg(feature = "paint")]
|
|
for (nid, &idx) in &ifc_items.whole {
|
|
if let (Some(rect), Some(style)) = (rects.get(nid), styles.get(nid)) {
|
|
let mut origin = crate::inline::content_origin(rect, style);
|
|
let cw = crate::inline::content_width(rect, style);
|
|
// A table cell stretched taller than its text (row height from a
|
|
// neighbor) aligns its content per vertical-align; the pure-text
|
|
// leaf path has no inner box to align, so shift the pinned origin
|
|
// by the leftover space instead.
|
|
if let Some(va @ (crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom)) = style.vertical_align {
|
|
let (_, th) = engine.measure(idx, Some(cw));
|
|
let content_h = rect.height
|
|
- style.padding.top
|
|
- style.padding.bottom
|
|
- style.border.top
|
|
- style.border.bottom;
|
|
let free = (content_h - th).max(0.0);
|
|
origin.1 += if va == crate::VerticalAlign::Middle { free / 2.0 } else { free };
|
|
}
|
|
// The shaped text is this container's own content, so an
|
|
// `overflow: hidden` on the container clips it (this is what keeps
|
|
// the 1x1 "visually hidden" skip-link box from painting its text,
|
|
// now that the text is one leaf rather than clipped word boxes).
|
|
// Clips live in screen space; the container's own box joins the
|
|
// chain shifted by its accumulated translate.
|
|
let inherited = clip_rects.get(nid).copied().flatten();
|
|
let clip = if style.overflow_hidden {
|
|
let (tx, ty) = translates.get(nid).copied().unwrap_or((0.0, 0.0));
|
|
let own = overflow_clip_rect(rect, style, tx, ty);
|
|
Some(match inherited {
|
|
Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()),
|
|
None => own,
|
|
})
|
|
} else {
|
|
inherited
|
|
};
|
|
engine.finalize(idx, origin, cw, clip);
|
|
}
|
|
}
|
|
// Anonymous run leaves have no DOM node and no border/padding of their
|
|
// own: the leaf rect IS the content box. Clipping comes from the parent
|
|
// block (its inherited chain, plus its own overflow like any child).
|
|
#[cfg(feature = "paint")]
|
|
for (parent, items) in &ifc_items.runs {
|
|
let inherited = clip_rects.get(parent).copied().flatten();
|
|
let clip = match (styles.get(parent), rects.get(parent)) {
|
|
(Some(style), Some(prect)) if style.overflow_hidden => {
|
|
let (tx, ty) = translates.get(parent).copied().unwrap_or((0.0, 0.0));
|
|
let own = overflow_clip_rect(prect, style, tx, ty);
|
|
Some(match inherited {
|
|
Some(c) => c.intersect(&own).unwrap_or(crate::Rect::default()),
|
|
None => own,
|
|
})
|
|
}
|
|
_ => inherited,
|
|
};
|
|
for &idx in items {
|
|
if let Some(rect) = anon_rects.get(&idx) {
|
|
engine.finalize(idx, (rect.x, rect.y), rect.width, clip);
|
|
}
|
|
}
|
|
}
|
|
|
|
let generated_boxes = ifc_items
|
|
.generated
|
|
.iter()
|
|
.zip(generated_rects)
|
|
.filter_map(|(generated, rect)| {
|
|
rect.map(|rect| GeneratedBox {
|
|
host: generated.host,
|
|
kind: generated.kind,
|
|
rect,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
(
|
|
DomLayout {
|
|
rects,
|
|
styles,
|
|
clip_rects,
|
|
translates,
|
|
text_runs,
|
|
#[cfg(feature = "paint")]
|
|
text_engine: engine,
|
|
#[cfg(feature = "paint")]
|
|
ifc_items: ifc_items.whole,
|
|
#[cfg(feature = "paint")]
|
|
run_ifc_items: ifc_items.runs,
|
|
generated_boxes,
|
|
},
|
|
signature,
|
|
query_stats,
|
|
cascade_time,
|
|
)
|
|
}
|
|
|
|
fn container_snapshot(
|
|
tree: &DomTree,
|
|
layout: &DomLayout,
|
|
) -> crate::css::ContainerSnapshot {
|
|
let root_font_size = tree
|
|
.descendants(tree.document())
|
|
.into_iter()
|
|
.find(|id| tree.get_node(*id).is_some_and(|node| node.is_element()))
|
|
.and_then(|id| layout.styles.get(&id))
|
|
.and_then(|style| style.font_size)
|
|
.filter(|size| size.is_finite() && *size > 0.0)
|
|
.unwrap_or(16.0);
|
|
let mut snapshot = crate::css::ContainerSnapshot {
|
|
root_font_size,
|
|
..Default::default()
|
|
};
|
|
for (&id, style) in &layout.styles {
|
|
let available_type = if style.display_contents {
|
|
crate::ContainerType::Normal
|
|
} else {
|
|
effective_container_type(style)
|
|
};
|
|
if (style.container_type == crate::ContainerType::Normal
|
|
&& style.container_names.is_empty())
|
|
|| style.display == crate::Display::None
|
|
{
|
|
continue;
|
|
}
|
|
let rect = layout.rects.get(&id);
|
|
// An inline box that computes to `container-type` is still the
|
|
// nearest matching query container even when size containment cannot
|
|
// apply to it. Inline layout may flatten that box and omit a DOM rect,
|
|
// but dropping it from the snapshot would incorrectly let the query
|
|
// fall through to a farther ancestor. Its queried axes are
|
|
// unavailable, so the placeholder geometry is intentionally unused.
|
|
if rect.is_none() && available_type != crate::ContainerType::Normal {
|
|
continue;
|
|
}
|
|
let horizontal_edges =
|
|
style.border.left + style.border.right + style.padding.left + style.padding.right;
|
|
let vertical_edges =
|
|
style.border.top + style.border.bottom + style.padding.top + style.padding.bottom;
|
|
snapshot.boxes.insert(
|
|
id,
|
|
crate::css::ContainerBox {
|
|
container_type: style.container_type,
|
|
available_type,
|
|
names: style.container_names.clone(),
|
|
content_width: rect
|
|
.map(|rect| (rect.width - horizontal_edges).max(0.0))
|
|
.unwrap_or(0.0),
|
|
content_height: rect
|
|
.map(|rect| (rect.height - vertical_edges).max(0.0))
|
|
.unwrap_or(0.0),
|
|
font_size: style
|
|
.font_size
|
|
.filter(|size| size.is_finite() && *size > 0.0)
|
|
.unwrap_or(16.0),
|
|
},
|
|
);
|
|
}
|
|
snapshot
|
|
}
|
|
|
|
/// HTML table auto-layout approximation: for each `<tr>`, the last `<td>`/
|
|
/// `<th>` child with no explicit width absorbs the row's leftover space. Real
|
|
/// browsers run a genuine column-width-negotiation algorithm across every row
|
|
/// in a table; giving `flex_grow` to *every* auto-width cell approximates that
|
|
/// badly; multiple growing siblings compete and the split becomes sensitive to
|
|
/// each cell's own content size, which looks like near-random drift row to
|
|
/// row. Growing only the trailing auto cell matches the extremely common
|
|
/// layout intent (fixed-size leading cells, one expanding trailing cell) and
|
|
/// leaves the others shrink-to-fit, matching a shrink-to-fit table exactly
|
|
/// when there is no surplus width to distribute in the first place.
|
|
fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
|
|
let is_tag = |id: NodeId, tags: &[&str]| -> bool {
|
|
match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) {
|
|
Some(local) => tags.contains(&local.as_str()),
|
|
None => false,
|
|
}
|
|
};
|
|
for tr in tree.descendants(tree.document()) {
|
|
if !is_tag(tr, &["tr"]) {
|
|
continue;
|
|
}
|
|
let last_auto_cell = tree.children(tr).into_iter().rev().find(|&cid| {
|
|
is_tag(cid, &["td", "th"])
|
|
&& styles.get(&cid).map(|s| s.width == crate::Dimension::Auto && s.flex_grow.is_none()).unwrap_or(false)
|
|
});
|
|
if let Some(cid) = last_auto_cell {
|
|
if let Some(style) = styles.get_mut(&cid) {
|
|
style.flex_grow = Some(1.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `border-spacing` (from CSS or the `cellspacing` attribute) is set on a
|
|
/// `<table>`, but taffy has no native table display mode: our table is a
|
|
/// column-flex stack of row-flex `<tr>`s, so the gap that separates cells has
|
|
/// to live on each row individually. Distribute the table's border-spacing
|
|
/// down as the table's own row gap (space between stacked `<tr>`s) and every
|
|
/// descendant `<tr>`'s column gap (space between cells within a row), without
|
|
/// crossing into a nested `<table>`'s own scope.
|
|
fn propagate_border_spacing(tree: &DomTree, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
|
|
fn local_name(tree: &DomTree, id: NodeId) -> Option<String> {
|
|
tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string()))
|
|
}
|
|
|
|
fn apply_to_rows(tree: &DomTree, id: NodeId, h: f32, v: f32, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
|
|
for cid in tree.children(id) {
|
|
if local_name(tree, cid).as_deref() == Some("table") {
|
|
continue;
|
|
}
|
|
if local_name(tree, cid).as_deref() == Some("tr") {
|
|
if let Some(s) = styles.get_mut(&cid) {
|
|
s.column_gap = Some(h);
|
|
s.row_gap = Some(v);
|
|
s.column_gap_expression = None;
|
|
s.row_gap_expression = None;
|
|
}
|
|
}
|
|
apply_to_rows(tree, cid, h, v, styles);
|
|
}
|
|
}
|
|
|
|
for id in tree.descendants(tree.document()) {
|
|
if local_name(tree, id).as_deref() != Some("table") {
|
|
continue;
|
|
}
|
|
let Some(table_style) = styles.get(&id) else {
|
|
continue;
|
|
};
|
|
let (h, v) = table_spacing(table_style);
|
|
if let Some(s) = styles.get_mut(&id) {
|
|
s.row_gap = Some(v);
|
|
s.row_gap_expression = None;
|
|
}
|
|
apply_to_rows(tree, id, h, v, styles);
|
|
}
|
|
}
|
|
|
|
/// Walk the tree; for each `display: grid` element that declares
|
|
/// `grid-template-areas`, resolve each direct child's `grid-area` name to a
|
|
/// taffy line placement. This is how Vector 2022 (and most grid layouts) place
|
|
/// their header/sidebar/content/footer regions.
|
|
fn resolve_grid_areas(
|
|
tree: &DomTree,
|
|
root: NodeId,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
) {
|
|
let mut stack = vec![root];
|
|
while let Some(id) = stack.pop() {
|
|
for cid in tree.children(id) {
|
|
stack.push(cid);
|
|
}
|
|
let (areas, col_lines, row_lines) = match styles.get(&id) {
|
|
Some(s) if s.display == crate::Display::Grid => (
|
|
s.grid_areas.clone().filter(|a| !a.is_empty()),
|
|
s.grid_col_line_names.clone(),
|
|
s.grid_row_line_names.clone(),
|
|
),
|
|
_ => continue,
|
|
};
|
|
if areas.is_none() && col_lines.is_none() && row_lines.is_none() {
|
|
continue;
|
|
}
|
|
|
|
if let Some(areas) = &areas {
|
|
// name -> (row_start, row_end, col_start, col_end) in 0-based track indices.
|
|
let mut spans: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
|
|
for (r, row) in areas.iter().enumerate() {
|
|
for (c, name) in row.iter().enumerate() {
|
|
if name == "." {
|
|
continue;
|
|
}
|
|
spans
|
|
.entry(name.clone())
|
|
.and_modify(|s| {
|
|
s.0 = s.0.min(r);
|
|
s.1 = s.1.max(r);
|
|
s.2 = s.2.min(c);
|
|
s.3 = s.3.max(c);
|
|
})
|
|
.or_insert((r, r, c, c));
|
|
}
|
|
}
|
|
|
|
for cid in tree.children(id) {
|
|
let Some(cstyle) = styles.get_mut(&cid) else { continue };
|
|
let Some(name) = cstyle.grid_area_name.clone() else { continue };
|
|
if let Some(&(r0, r1, c0, c1)) = spans.get(&name) {
|
|
use taffy::style_helpers::line;
|
|
cstyle.grid_row = Some(taffy::Line {
|
|
start: line((r0 + 1) as i16),
|
|
end: line((r1 + 2) as i16),
|
|
});
|
|
cstyle.grid_column = Some(taffy::Line {
|
|
start: line((c0 + 1) as i16),
|
|
end: line((c1 + 2) as i16),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Named grid lines: resolve children placed with `grid-column`/`grid-row`
|
|
// values that reference a line name against this container's maps.
|
|
if col_lines.is_some() || row_lines.is_some() {
|
|
for cid in tree.children(id) {
|
|
let Some(cstyle) = styles.get_mut(&cid) else { continue };
|
|
if let (Some(raw), Some(map)) = (cstyle.grid_column_raw.clone(), &col_lines) {
|
|
if let Some(l) = resolve_named_placement(&raw, map) {
|
|
cstyle.grid_column = Some(l);
|
|
}
|
|
}
|
|
if let (Some(raw), Some(map)) = (cstyle.grid_row_raw.clone(), &row_lines) {
|
|
if let Some(l) = resolve_named_placement(&raw, map) {
|
|
cstyle.grid_row = Some(l);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ColumnSubgridWrapper {
|
|
node: taffy::NodeId,
|
|
gap: f32,
|
|
start_mbp: f32,
|
|
end_mbp: f32,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ColumnSubgridLeaf {
|
|
node: taffy::NodeId,
|
|
dom: NodeId,
|
|
column: usize,
|
|
gap: f32,
|
|
start_mbp: f32,
|
|
end_mbp: f32,
|
|
}
|
|
|
|
struct ColumnSubgridPlan {
|
|
parent: taffy::NodeId,
|
|
track_count: usize,
|
|
parent_gap: f32,
|
|
wrappers: Vec<ColumnSubgridWrapper>,
|
|
leaves: Vec<ColumnSubgridLeaf>,
|
|
}
|
|
|
|
fn is_full_span_column_subgrid(style: &crate::LayoutStyle) -> bool {
|
|
if style.display != crate::Display::Grid
|
|
|| !style.grid_template_columns_subgrid
|
|
|| style.overflow_hidden
|
|
|| style.position == Some(taffy::Position::Absolute)
|
|
|| style.width != crate::Dimension::Auto
|
|
|| style.justify_self.is_some_and(|value| value != taffy::AlignSelf::STRETCH)
|
|
|| style.margin_auto[1]
|
|
|| style.margin_auto[3]
|
|
{
|
|
return false;
|
|
}
|
|
let Some(line) = &style.grid_column else {
|
|
return false;
|
|
};
|
|
matches!(
|
|
(&line.start, &line.end),
|
|
(taffy::GridPlacement::Line(start), taffy::GridPlacement::Line(end))
|
|
if start.as_i16() == 1 && end.as_i16() == -1
|
|
)
|
|
}
|
|
|
|
/// Collect the deliberately bounded Grid Level 2 subset used by broad
|
|
/// "aligned rows" components: a full-span column subgrid, optionally nested
|
|
/// through more full-span column subgrids, whose final children auto-place one
|
|
/// per inherited column. Partial spans, explicit leaf placement, independent
|
|
/// formatting contexts, and authored inline sizing are left on the existing
|
|
/// fallback rather than being represented inaccurately.
|
|
fn collect_column_subgrid_descendants(
|
|
tree: &DomTree,
|
|
dom: NodeId,
|
|
track_count: usize,
|
|
taffy_by_dom: &HashMap<NodeId, taffy::NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
start_mbp: f32,
|
|
end_mbp: f32,
|
|
depth: usize,
|
|
wrappers: &mut Vec<ColumnSubgridWrapper>,
|
|
leaves: &mut Vec<ColumnSubgridLeaf>,
|
|
) -> bool {
|
|
if depth > 8 {
|
|
return false;
|
|
}
|
|
let Some(style) = styles.get(&dom) else {
|
|
return false;
|
|
};
|
|
let Some(&node) = taffy_by_dom.get(&dom) else {
|
|
return false;
|
|
};
|
|
let start_mbp = start_mbp + style.margin.left + style.border.left + style.padding.left;
|
|
let end_mbp = end_mbp + style.margin.right + style.border.right + style.padding.right;
|
|
let gap = style.column_gap.unwrap_or(0.0);
|
|
wrappers.push(ColumnSubgridWrapper { node, gap, start_mbp, end_mbp });
|
|
|
|
let children: Vec<NodeId> = tree
|
|
.children(dom)
|
|
.into_iter()
|
|
.filter(|child| {
|
|
styles
|
|
.get(child)
|
|
.map(|style| style.display != crate::Display::None)
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
if children.is_empty() {
|
|
return false;
|
|
}
|
|
let nested = children
|
|
.iter()
|
|
.filter(|child| styles.get(child).is_some_and(is_full_span_column_subgrid))
|
|
.count();
|
|
if nested > 0 {
|
|
if nested != children.len() {
|
|
return false;
|
|
}
|
|
return children.into_iter().all(|child| {
|
|
collect_column_subgrid_descendants(
|
|
tree,
|
|
child,
|
|
track_count,
|
|
taffy_by_dom,
|
|
styles,
|
|
start_mbp,
|
|
end_mbp,
|
|
depth + 1,
|
|
wrappers,
|
|
leaves,
|
|
)
|
|
});
|
|
}
|
|
|
|
let flow = style.grid_auto_flow.unwrap_or(taffy::GridAutoFlow::Row);
|
|
if !matches!(flow, taffy::GridAutoFlow::Row | taffy::GridAutoFlow::RowDense) {
|
|
return false;
|
|
}
|
|
for (index, child) in children.into_iter().enumerate() {
|
|
let Some(child_style) = styles.get(&child) else {
|
|
return false;
|
|
};
|
|
// This first subset intentionally excludes spanning and explicitly
|
|
// placed items. It is the common data-row/card-row pattern and keeps
|
|
// each descendant contribution attributable to one ancestor track.
|
|
if child_style.grid_column.is_some()
|
|
|| child_style.position == Some(taffy::Position::Absolute)
|
|
|| child_style.margin_auto[1]
|
|
|| child_style.margin_auto[3]
|
|
{
|
|
return false;
|
|
}
|
|
let Some(&node) = taffy_by_dom.get(&child) else {
|
|
return false;
|
|
};
|
|
leaves.push(ColumnSubgridLeaf {
|
|
node,
|
|
dom: child,
|
|
column: index % track_count,
|
|
gap,
|
|
start_mbp,
|
|
end_mbp,
|
|
});
|
|
}
|
|
true
|
|
}
|
|
|
|
fn fixed_grid_tracks(widths: &[f32]) -> Vec<taffy::GridTemplateComponent<String>> {
|
|
widths
|
|
.iter()
|
|
.map(|width| {
|
|
taffy::GridTemplateComponent::Single(taffy::MinMax {
|
|
min: taffy::MinTrackSizingFunction::length((*width).max(0.0)),
|
|
max: taffy::MaxTrackSizingFunction::length((*width).max(0.0)),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Resolve a safe full-span column-subgrid subset in two passes.
|
|
///
|
|
/// Gecko first collects subgrid descendants into the nearest non-subgridded
|
|
/// ancestor's track sizing, then copies that ancestor's *used* track sizes
|
|
/// down the chain. Its descendant contributions add accumulated edge
|
|
/// margin/border/padding and center a custom subgrid gap over the ancestor
|
|
/// gap. Taffy has no subgrid primitive, so we reproduce those same operations
|
|
/// only for definite, all-auto, single-span rows. Once every max-content
|
|
/// growth limit fits, `justify-content:normal` stretches all auto tracks by an
|
|
/// equal share; narrower/cyclic cases decline this fast path.
|
|
///
|
|
/// The current style model does not expose orthogonal writing modes and emits
|
|
/// Taffy's default LTR direction for every grid; this physical-column reduction
|
|
/// therefore cannot accidentally enter an RTL/orthogonal layout path that the
|
|
/// surrounding renderer does not implement yet. Percentage-width parents are
|
|
/// accepted only after the preliminary layout produced finite, explicit used
|
|
/// track sizes; that resolved track sum is the definite basis for pass two.
|
|
fn apply_full_span_column_subgrids<F>(
|
|
tree: &DomTree,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
mut measure_max_content: F,
|
|
) -> bool
|
|
where
|
|
F: FnMut(&mut TaffyTree<usize>, taffy::NodeId) -> Option<f32>,
|
|
{
|
|
let taffy_by_dom: HashMap<NodeId, taffy::NodeId> =
|
|
id_map.iter().map(|(taffy, dom)| (*dom, *taffy)).collect();
|
|
let mut plans = Vec::new();
|
|
|
|
for (&dom, style) in styles {
|
|
if style.display != crate::Display::Grid
|
|
|| style.grid_template_columns_subgrid
|
|
|| !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_))
|
|
|| style.justify_content.is_some_and(|value| value != taffy::JustifyContent::STRETCH)
|
|
{
|
|
continue;
|
|
}
|
|
let all_auto = !style.grid_template_columns.is_empty()
|
|
&& style.grid_template_columns.iter().all(|track| {
|
|
matches!(
|
|
track,
|
|
taffy::GridTemplateComponent::Single(size)
|
|
if size.min.is_auto() && size.max.is_auto()
|
|
)
|
|
});
|
|
if !all_auto || style.grid_template_columns.len() > 32 {
|
|
continue;
|
|
}
|
|
let track_count = style.grid_template_columns.len();
|
|
let direct: Vec<NodeId> = tree
|
|
.children(dom)
|
|
.into_iter()
|
|
.filter(|child| styles.get(child).is_some_and(|style| style.display != crate::Display::None))
|
|
.collect();
|
|
if direct.is_empty()
|
|
|| !direct
|
|
.iter()
|
|
.all(|child| styles.get(child).is_some_and(is_full_span_column_subgrid))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(&parent) = taffy_by_dom.get(&dom) else {
|
|
continue;
|
|
};
|
|
let mut wrappers = Vec::new();
|
|
let mut leaves = Vec::new();
|
|
if !direct.into_iter().all(|child| {
|
|
collect_column_subgrid_descendants(
|
|
tree,
|
|
child,
|
|
track_count,
|
|
&taffy_by_dom,
|
|
styles,
|
|
0.0,
|
|
0.0,
|
|
0,
|
|
&mut wrappers,
|
|
&mut leaves,
|
|
)
|
|
}) || leaves.is_empty()
|
|
{
|
|
continue;
|
|
}
|
|
plans.push(ColumnSubgridPlan {
|
|
parent,
|
|
track_count,
|
|
parent_gap: style.column_gap.unwrap_or(0.0),
|
|
wrappers,
|
|
leaves,
|
|
});
|
|
}
|
|
|
|
let mut changed = false;
|
|
for plan in plans {
|
|
let target_track_sum = match taffy_tree.detailed_layout_info(plan.parent) {
|
|
taffy::tree::DetailedLayoutInfo::Grid(info)
|
|
if info.columns.negative_implicit_tracks == 0
|
|
&& info.columns.positive_implicit_tracks == 0
|
|
&& info.columns.explicit_tracks as usize == plan.track_count =>
|
|
{
|
|
info.columns.sizes.iter().sum::<f32>()
|
|
}
|
|
_ => continue,
|
|
};
|
|
if !target_track_sum.is_finite() || target_track_sum <= 0.0 {
|
|
continue;
|
|
}
|
|
let mut max_content = vec![0.0f32; plan.track_count];
|
|
let mut valid = true;
|
|
for leaf in &plan.leaves {
|
|
let Some(mut contribution) = measure_max_content(taffy_tree, leaf.node) else {
|
|
valid = false;
|
|
break;
|
|
};
|
|
let Some(leaf_style) = styles.get(&leaf.dom) else {
|
|
valid = false;
|
|
break;
|
|
};
|
|
contribution += leaf_style.margin.left + leaf_style.margin.right;
|
|
if plan.track_count > 1 {
|
|
let gap_delta = leaf.gap - plan.parent_gap;
|
|
contribution += if leaf.column == 0 || leaf.column + 1 == plan.track_count {
|
|
gap_delta / 2.0
|
|
} else {
|
|
gap_delta
|
|
};
|
|
}
|
|
if leaf.column == 0 {
|
|
contribution += leaf.start_mbp;
|
|
}
|
|
if leaf.column + 1 == plan.track_count {
|
|
contribution += leaf.end_mbp;
|
|
}
|
|
max_content[leaf.column] = max_content[leaf.column].max(contribution.max(0.0));
|
|
}
|
|
let max_sum: f32 = max_content.iter().sum();
|
|
if !valid || max_sum > target_track_sum + 0.01 {
|
|
continue;
|
|
}
|
|
let stretch = (target_track_sum - max_sum) / plan.track_count as f32;
|
|
let used: Vec<f32> = max_content.iter().map(|width| width + stretch).collect();
|
|
if used.iter().any(|width| !width.is_finite() || *width < 0.0) {
|
|
continue;
|
|
}
|
|
|
|
// Validate the entire copied chain before mutating anything. A large
|
|
// edge MBP or gap delta can exhaust an outer track; declining the plan
|
|
// atomically is safer than leaving only the ancestor frozen.
|
|
let mut copied_wrappers = Vec::with_capacity(plan.wrappers.len());
|
|
for wrapper in &plan.wrappers {
|
|
let mut copied = used.clone();
|
|
if plan.track_count > 1 {
|
|
let root_half = plan.parent_gap / 2.0;
|
|
let child_half = wrapper.gap / 2.0;
|
|
for (index, width) in copied.iter_mut().enumerate() {
|
|
*width += if index == 0 || index + 1 == plan.track_count {
|
|
root_half - child_half
|
|
} else {
|
|
plan.parent_gap - wrapper.gap
|
|
};
|
|
}
|
|
}
|
|
copied[0] -= wrapper.start_mbp;
|
|
copied[plan.track_count - 1] -= wrapper.end_mbp;
|
|
if copied.iter().any(|width| !width.is_finite() || *width < 0.0)
|
|
|| taffy_tree.style(wrapper.node).is_err()
|
|
{
|
|
valid = false;
|
|
break;
|
|
}
|
|
copied_wrappers.push((wrapper.node, copied));
|
|
}
|
|
if !valid || taffy_tree.style(plan.parent).is_err() {
|
|
continue;
|
|
}
|
|
|
|
let mut parent_style = taffy_tree.style(plan.parent).unwrap().clone();
|
|
parent_style.grid_template_columns = fixed_grid_tracks(&used);
|
|
let _ = taffy_tree.set_style(plan.parent, parent_style);
|
|
for (node, copied) in copied_wrappers {
|
|
let mut style = taffy_tree.style(node).unwrap().clone();
|
|
style.grid_template_columns = fixed_grid_tracks(&copied);
|
|
let _ = taffy_tree.set_style(node, style);
|
|
}
|
|
changed = true;
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Resolve a raw `grid-column`/`grid-row` value that names grid lines into a
|
|
/// numeric `taffy::Line`, using `map` (line-name -> 1-based line number). Handles
|
|
/// `a / b` (each side a name, integer, or `span n`) and the single-ident
|
|
/// `grid-column: foo` area shorthand (`foo-start / foo-end`). Returns `None` when
|
|
/// a referenced name is absent, leaving the item to auto-place.
|
|
fn resolve_named_placement(
|
|
raw: &str,
|
|
map: &HashMap<String, i16>,
|
|
) -> Option<taffy::Line<taffy::GridPlacement>> {
|
|
use taffy::style_helpers::{line, span};
|
|
let side = |tok: &str, is_start: bool| -> Option<taffy::GridPlacement> {
|
|
let t = tok.trim();
|
|
if t.is_empty() || t.eq_ignore_ascii_case("auto") {
|
|
return Some(taffy::GridPlacement::Auto);
|
|
}
|
|
if let Some(n) = t.strip_prefix("span") {
|
|
if let Ok(s) = n.trim().parse::<u16>() {
|
|
return Some(span(s));
|
|
}
|
|
}
|
|
if let Ok(i) = t.parse::<i16>() {
|
|
return Some(line(i));
|
|
}
|
|
map.get(t)
|
|
.or_else(|| map.get(&format!("{t}-{}", if is_start { "start" } else { "end" })))
|
|
.map(|&l| line(l))
|
|
};
|
|
if let Some((a, b)) = raw.split_once('/') {
|
|
Some(taffy::Line {
|
|
start: side(a, true)?,
|
|
end: side(b, false)?,
|
|
})
|
|
} else {
|
|
let name = raw.trim();
|
|
if let Some(&s) = map.get(&format!("{name}-start")) {
|
|
let end = map
|
|
.get(&format!("{name}-end"))
|
|
.map(|&e| line(e))
|
|
.unwrap_or(taffy::GridPlacement::Auto);
|
|
return Some(taffy::Line { start: line(s), end });
|
|
}
|
|
map.get(name).map(|&s| taffy::Line { start: line(s), end: taffy::GridPlacement::Auto })
|
|
}
|
|
}
|
|
|
|
fn compute_absolute_rects(
|
|
taffy_tree: &TaffyTree<usize>,
|
|
taffy_id: taffy::NodeId,
|
|
abs_x: f32,
|
|
abs_y: f32,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
words: &HashMap<taffy::NodeId, (NodeId, String)>,
|
|
rects: &mut HashMap<NodeId, Rect>,
|
|
text_runs: &mut HashMap<NodeId, Vec<(Rect, String)>>,
|
|
anon_rects: &mut HashMap<usize, Rect>,
|
|
generated_nodes: &HashMap<taffy::NodeId, usize>,
|
|
generated_rects: &mut [Option<Rect>],
|
|
) {
|
|
if let Ok(layout) = taffy_tree.layout(taffy_id) {
|
|
let x = abs_x + layout.location.x;
|
|
let y = abs_y + layout.location.y;
|
|
let rect = Rect { x, y, width: layout.size.width, height: layout.size.height };
|
|
|
|
if let Some(dom_id) = id_map.get(&taffy_id) {
|
|
rects.insert(*dom_id, rect);
|
|
} else if let Some(&item) = taffy_tree.get_node_context(taffy_id) {
|
|
// A taffy leaf with an engine-item context but no DOM id is an
|
|
// anonymous inline-run leaf (see `build_mixed_block`); record its
|
|
// final rect by item index so the finalize pass can pin it.
|
|
anon_rects.insert(item, rect);
|
|
}
|
|
if let Some(index) = generated_nodes.get(&taffy_id) {
|
|
generated_rects[*index] = Some(rect);
|
|
}
|
|
// A word leaf's dom_id is its owning text node, shared by every other
|
|
// word from the same node, so this appends rather than overwrites.
|
|
if let Some((text_dom_id, word)) = words.get(&taffy_id) {
|
|
text_runs.entry(*text_dom_id).or_default().push((rect, word.clone()));
|
|
}
|
|
|
|
if let Ok(children) = taffy_tree.children(taffy_id) {
|
|
for child_id in children {
|
|
compute_absolute_rects(
|
|
taffy_tree,
|
|
child_id,
|
|
x,
|
|
y,
|
|
id_map,
|
|
words,
|
|
rects,
|
|
text_runs,
|
|
anon_rects,
|
|
generated_nodes,
|
|
generated_rects,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct StaticPositionCandidate {
|
|
child: taffy::NodeId,
|
|
target: taffy::NodeId,
|
|
inline_axis: bool,
|
|
block_axis: bool,
|
|
}
|
|
|
|
/// Attach positioned boxes to their CSS containing block rather than their
|
|
/// immediate DOM parent.
|
|
///
|
|
/// Taffy resolves an absolute child's insets against its direct layout-tree
|
|
/// parent. CSS instead uses the nearest positioned or transformed ancestor;
|
|
/// fixed boxes use the nearest transformed ancestor or the initial containing
|
|
/// block. A box with a fully-auto axis first remains in its original formatting
|
|
/// context so taffy can produce the placeholder-like static coordinate. The
|
|
/// caller harvests that coordinate and reparents it in a bounded second pass.
|
|
fn reparent_inset_positioned_nodes(
|
|
tree: &DomTree,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
taffy_root: taffy::NodeId,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Vec<StaticPositionCandidate> {
|
|
let reverse: HashMap<NodeId, taffy::NodeId> =
|
|
id_map.iter().map(|(&taffy_id, &dom_id)| (dom_id, taffy_id)).collect();
|
|
let mut nearest_abs_cb_for_children: HashMap<NodeId, taffy::NodeId> = HashMap::new();
|
|
let mut nearest_fixed_cb_for_children: HashMap<NodeId, taffy::NodeId> = HashMap::new();
|
|
let mut static_candidates = Vec::new();
|
|
|
|
for dom_id in tree.descendants(tree.document()) {
|
|
let Some(style) = styles.get(&dom_id) else { continue };
|
|
let parent = tree.get_node(dom_id).and_then(|node| node.parent);
|
|
let inherited_abs_cb = parent
|
|
.and_then(|id| nearest_abs_cb_for_children.get(&id).copied())
|
|
.unwrap_or(taffy_root);
|
|
let inherited_fixed_cb = parent
|
|
.and_then(|id| nearest_fixed_cb_for_children.get(&id).copied())
|
|
.unwrap_or(taffy_root);
|
|
|
|
// Record this before any candidate early-exit so all descendants get
|
|
// O(1) nearest-containing-block lookups. Positioned and transformed
|
|
// boxes capture absolute descendants; only transformed boxes capture
|
|
// fixed descendants. The full walk stays O(n).
|
|
let own_box = reverse.get(&dom_id).copied();
|
|
let establishes_cb = style.establishes_positioning_containing_block();
|
|
let abs_child_cb = if style.position.is_some() || establishes_cb {
|
|
own_box.unwrap_or(inherited_abs_cb)
|
|
} else {
|
|
inherited_abs_cb
|
|
};
|
|
let fixed_child_cb = if establishes_cb {
|
|
own_box.unwrap_or(inherited_fixed_cb)
|
|
} else {
|
|
inherited_fixed_cb
|
|
};
|
|
nearest_abs_cb_for_children.insert(dom_id, abs_child_cb);
|
|
nearest_fixed_cb_for_children.insert(dom_id, fixed_child_cb);
|
|
|
|
if !matches!(style.position, Some(taffy::Position::Absolute)) {
|
|
continue;
|
|
}
|
|
let has_block_inset = style.inset[0].is_some() || style.inset[2].is_some();
|
|
let has_inline_inset = style.inset[1].is_some() || style.inset[3].is_some();
|
|
let Some(&child) = reverse.get(&dom_id) else { continue };
|
|
let target = if style.position_fixed {
|
|
inherited_fixed_cb
|
|
} else {
|
|
inherited_abs_cb
|
|
};
|
|
let Some(current) = taffy_tree.parent(child) else { continue };
|
|
if current == target {
|
|
continue;
|
|
}
|
|
if !has_block_inset || !has_inline_inset {
|
|
static_candidates.push(StaticPositionCandidate {
|
|
child,
|
|
target,
|
|
inline_axis: !has_inline_inset,
|
|
block_axis: !has_block_inset,
|
|
});
|
|
continue;
|
|
}
|
|
if taffy_tree.remove_child(current, child).is_ok() {
|
|
let _ = taffy_tree.add_child(target, child);
|
|
}
|
|
}
|
|
static_candidates
|
|
}
|
|
|
|
fn taffy_global_origin(
|
|
taffy_tree: &TaffyTree<usize>,
|
|
node: taffy::NodeId,
|
|
) -> Option<(f32, f32)> {
|
|
let mut current = Some(node);
|
|
let mut x = 0.0;
|
|
let mut y = 0.0;
|
|
while let Some(id) = current {
|
|
let layout = taffy_tree.layout(id).ok()?;
|
|
x += layout.location.x;
|
|
y += layout.location.y;
|
|
current = taffy_tree.parent(id);
|
|
}
|
|
Some((x, y))
|
|
}
|
|
|
|
fn collect_taffy_global_rects(
|
|
taffy_tree: &TaffyTree<usize>,
|
|
node: taffy::NodeId,
|
|
parent_x: f32,
|
|
parent_y: f32,
|
|
rects: &mut HashMap<taffy::NodeId, Rect>,
|
|
) {
|
|
let Ok(layout) = taffy_tree.layout(node) else {
|
|
return;
|
|
};
|
|
let x = parent_x + layout.location.x;
|
|
let y = parent_y + layout.location.y;
|
|
rects.insert(
|
|
node,
|
|
Rect {
|
|
x,
|
|
y,
|
|
width: layout.size.width,
|
|
height: layout.size.height,
|
|
},
|
|
);
|
|
for child in taffy_tree.children(node).unwrap_or_default() {
|
|
collect_taffy_global_rects(taffy_tree, child, x, y, rects);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct FloatBand {
|
|
top: f32,
|
|
bottom: f32,
|
|
left: f32,
|
|
right: f32,
|
|
side: crate::Float,
|
|
}
|
|
|
|
fn narrow_node_to_float_band(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
node: taffy::NodeId,
|
|
preliminary_rects: &HashMap<taffy::NodeId, Rect>,
|
|
band: FloatBand,
|
|
) -> bool {
|
|
let Some(&rect) = preliminary_rects.get(&node) else {
|
|
return false;
|
|
};
|
|
if rect.y >= band.bottom
|
|
|| rect.y + rect.height <= band.top
|
|
|| rect.width <= 0.0
|
|
{
|
|
return false;
|
|
}
|
|
let Ok(current) = taffy_tree.style(node) else {
|
|
return false;
|
|
};
|
|
let Ok(layout) = taffy_tree.layout(node) else {
|
|
return false;
|
|
};
|
|
let mut narrowed = current.clone();
|
|
let (available, left_shift) = match band.side {
|
|
crate::Float::Right => {
|
|
if band.left >= rect.x + rect.width {
|
|
return false;
|
|
}
|
|
((band.left - rect.x).max(0.0), 0.0)
|
|
}
|
|
crate::Float::Left => {
|
|
if band.right <= rect.x {
|
|
return false;
|
|
}
|
|
let shift = (band.right - rect.x).max(0.0);
|
|
((rect.width - shift).max(0.0), shift)
|
|
}
|
|
};
|
|
if available >= rect.width - 0.01 {
|
|
return false;
|
|
}
|
|
let specified = if current.box_sizing == taffy::BoxSizing::ContentBox {
|
|
(available
|
|
- layout.padding.left
|
|
- layout.padding.right
|
|
- layout.border.left
|
|
- layout.border.right)
|
|
.max(0.0)
|
|
} else {
|
|
available
|
|
};
|
|
narrowed.size.width = taffy::Dimension::length(specified);
|
|
narrowed.max_size.width = taffy::Dimension::length(specified);
|
|
if left_shift > 0.0 {
|
|
narrowed.margin.left =
|
|
taffy::LengthPercentageAuto::length(layout.margin.left + left_shift);
|
|
}
|
|
taffy_tree.set_style(node, narrowed).is_ok()
|
|
}
|
|
|
|
fn grow_bfc_to_float_bottom(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
node: taffy::NodeId,
|
|
preliminary_rects: &HashMap<taffy::NodeId, Rect>,
|
|
float_bottom: f32,
|
|
) -> bool {
|
|
let Some(&rect) = preliminary_rects.get(&node) else {
|
|
return false;
|
|
};
|
|
let desired_border_height = (float_bottom - rect.y).max(0.0);
|
|
if desired_border_height <= rect.height + 0.01 {
|
|
return false;
|
|
}
|
|
let Ok(current) = taffy_tree.style(node) else {
|
|
return false;
|
|
};
|
|
let Ok(layout) = taffy_tree.layout(node) else {
|
|
return false;
|
|
};
|
|
let specified = if current.box_sizing == taffy::BoxSizing::ContentBox {
|
|
(desired_border_height
|
|
- layout.padding.top
|
|
- layout.padding.bottom
|
|
- layout.border.top
|
|
- layout.border.bottom)
|
|
.max(0.0)
|
|
} else {
|
|
desired_border_height
|
|
};
|
|
let mut grown = current.clone();
|
|
grown.min_size.height = taffy::Dimension::length(specified);
|
|
taffy_tree.set_style(node, grown).is_ok()
|
|
}
|
|
|
|
fn narrow_intersecting_descendants(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
reverse: &HashMap<NodeId, taffy::NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
ifc_items: &IfcRegistry,
|
|
preliminary_rects: &HashMap<taffy::NodeId, Rect>,
|
|
band: FloatBand,
|
|
) -> bool {
|
|
let Some(&node) = reverse.get(&id) else {
|
|
return false;
|
|
};
|
|
let Some(&rect) = preliminary_rects.get(&node) else {
|
|
return false;
|
|
};
|
|
if rect.y >= band.bottom || rect.y + rect.height <= band.top {
|
|
return false;
|
|
}
|
|
let Some(style) = styles.get(&id) else {
|
|
return false;
|
|
};
|
|
if style.display == crate::Display::None
|
|
|| style.float.is_some()
|
|
|| matches!(style.position, Some(taffy::Position::Absolute))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Float-avoiding formatting contexts move as one box. A shaped IFC or a
|
|
// leaf has no deeper block boundary at which its width can change, so it
|
|
// also consumes the current band as a unit.
|
|
let in_flow_element_children: Vec<NodeId> = tree
|
|
.children(id)
|
|
.into_iter()
|
|
.filter(|child| {
|
|
styles.get(child).map_or(false, |child_style| {
|
|
child_style.display != crate::Display::None
|
|
&& child_style.float.is_none()
|
|
&& !matches!(
|
|
child_style.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
})
|
|
})
|
|
.collect();
|
|
if establishes_block_formatting_context(style)
|
|
|| ifc_items.whole.contains_key(&id)
|
|
|| in_flow_element_children.is_empty()
|
|
{
|
|
return narrow_node_to_float_band(
|
|
taffy_tree,
|
|
node,
|
|
preliminary_rects,
|
|
band,
|
|
);
|
|
}
|
|
|
|
let mut changed = false;
|
|
for child in in_flow_element_children {
|
|
changed |= narrow_intersecting_descendants(
|
|
tree,
|
|
child,
|
|
taffy_tree,
|
|
reverse,
|
|
styles,
|
|
ifc_items,
|
|
preliminary_rects,
|
|
band,
|
|
);
|
|
}
|
|
changed
|
|
}
|
|
|
|
fn apply_float_continuations(
|
|
tree: &DomTree,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
ifc_items: &IfcRegistry,
|
|
) -> bool {
|
|
let reverse: HashMap<NodeId, taffy::NodeId> =
|
|
id_map.iter().map(|(&taffy, &dom)| (dom, taffy)).collect();
|
|
let Some(root) = id_map
|
|
.keys()
|
|
.copied()
|
|
.find(|node| taffy_tree.parent(*node).is_none())
|
|
else {
|
|
return false;
|
|
};
|
|
let mut preliminary_rects = HashMap::with_capacity(id_map.len());
|
|
collect_taffy_global_rects(
|
|
taffy_tree,
|
|
root,
|
|
0.0,
|
|
0.0,
|
|
&mut preliminary_rects,
|
|
);
|
|
let mut changed = false;
|
|
for continuation in &ifc_items.float_continuations {
|
|
let Some(&float_rect) = preliminary_rects.get(&continuation.float) else {
|
|
continue;
|
|
};
|
|
let Ok(float_layout) = taffy_tree.layout(continuation.float) else {
|
|
continue;
|
|
};
|
|
let Some(&flow_rect) = preliminary_rects.get(&continuation.flow) else {
|
|
continue;
|
|
};
|
|
let band = FloatBand {
|
|
top: float_rect.y - float_layout.margin.top,
|
|
bottom: float_rect.y + float_rect.height + float_layout.margin.bottom,
|
|
left: float_rect.x - float_layout.margin.left,
|
|
right: float_rect.x + float_rect.width + float_layout.margin.right,
|
|
side: continuation.side,
|
|
};
|
|
if band.bottom <= flow_rect.y + flow_rect.height + 0.01 {
|
|
continue;
|
|
}
|
|
|
|
// A non-BFC wrapper is transparent to the BFC's float manager. Visit
|
|
// the wrapper's following siblings, then repeat at each ancestor until
|
|
// (and including) the nearest ancestor BFC. Descend through ordinary
|
|
// blocks so only the leaf/block bands that actually intersect the
|
|
// float are narrowed; later siblings below the float stay full width.
|
|
let mut current = continuation.owner;
|
|
while let Some(parent) = tree.get_node(current).and_then(|node| node.parent)
|
|
{
|
|
let siblings = tree.children(parent);
|
|
let Some(index) = siblings.iter().position(|candidate| *candidate == current)
|
|
else {
|
|
break;
|
|
};
|
|
for sibling in &siblings[index + 1..] {
|
|
changed |= narrow_intersecting_descendants(
|
|
tree,
|
|
*sibling,
|
|
taffy_tree,
|
|
&reverse,
|
|
styles,
|
|
ifc_items,
|
|
&preliminary_rects,
|
|
band,
|
|
);
|
|
}
|
|
let reached_bfc = styles
|
|
.get(&parent)
|
|
.map(establishes_block_formatting_context)
|
|
.unwrap_or(false);
|
|
if reached_bfc {
|
|
if styles
|
|
.get(&parent)
|
|
.map(|style| matches!(style.height, crate::Dimension::Auto))
|
|
.unwrap_or(false)
|
|
{
|
|
if let Some(&bfc_node) = reverse.get(&parent) {
|
|
changed |=
|
|
grow_bfc_to_float_bottom(
|
|
taffy_tree,
|
|
bfc_node,
|
|
&preliminary_rects,
|
|
band.bottom,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
current = parent;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Recompute table row tracks from the cells' content at their final column
|
|
/// widths. Generic grid intrinsic sizing can retain a taller contribution
|
|
/// from an earlier, narrower nested-table measurement even after that child
|
|
/// resolves to a shorter final box. CSS tables instead finish columns first,
|
|
/// measure cells at those widths, then derive rows bottom-up.
|
|
fn apply_table_row_geometry(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
ifc_items: &IfcRegistry,
|
|
) -> bool {
|
|
let mut changed = false;
|
|
for (&table_node, row_minimums) in &ifc_items.table_rows {
|
|
let nrows = row_minimums.len();
|
|
if nrows == 0 {
|
|
continue;
|
|
}
|
|
let mut row_heights: Vec<f32> = row_minimums
|
|
.iter()
|
|
.map(|height| height.unwrap_or(0.0).max(0.0))
|
|
.collect();
|
|
let mut cells = Vec::new();
|
|
for cell in taffy_tree.children(table_node).unwrap_or_default() {
|
|
let (Ok(cell_style), Ok(layout)) =
|
|
(taffy_tree.style(cell), taffy_tree.layout(cell))
|
|
else {
|
|
continue;
|
|
};
|
|
let row = match cell_style.grid_row.start {
|
|
GridPlacement::Line(line) => (line.as_i16().max(1) as usize) - 1,
|
|
_ => continue,
|
|
};
|
|
let span = match cell_style.grid_row.end {
|
|
GridPlacement::Span(span) => (span as usize).max(1),
|
|
_ => 1,
|
|
};
|
|
if row >= nrows {
|
|
continue;
|
|
}
|
|
let edges = layout.padding.top
|
|
+ layout.padding.bottom
|
|
+ layout.border.top
|
|
+ layout.border.bottom;
|
|
// Taffy's content_size is the furthest content/child overflow in
|
|
// border-box coordinates (an empty padded cell reports its padding
|
|
// extent), so it is already the right natural border-box floor.
|
|
let mut natural = layout.content_size.height.max(edges);
|
|
if let Some(dom_id) = id_map.get(&cell) {
|
|
if let Some(style) = styles.get(dom_id) {
|
|
if let crate::Dimension::Px(height) = style.height {
|
|
let specified = if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
height + style.padding.top
|
|
+ style.padding.bottom
|
|
+ style.border.top
|
|
+ style.border.bottom
|
|
} else {
|
|
height
|
|
};
|
|
natural = natural.max(specified);
|
|
}
|
|
}
|
|
}
|
|
cells.push((cell, row, span.min(nrows - row), natural.max(0.0)));
|
|
}
|
|
|
|
// Non-spanning cells define their row directly.
|
|
for &(_, row, span, natural) in &cells {
|
|
if span == 1 {
|
|
row_heights[row] = row_heights[row].max(natural);
|
|
}
|
|
}
|
|
|
|
let table_dom = id_map.get(&table_node).copied();
|
|
let (row_gap, table_has_height) = table_dom
|
|
.and_then(|dom_id| styles.get(&dom_id))
|
|
.map(|style| {
|
|
(
|
|
table_spacing(style).1,
|
|
matches!(style.height, crate::Dimension::Px(_)),
|
|
)
|
|
})
|
|
.unwrap_or((0.0, false));
|
|
|
|
// A rowspan contributes only the shortfall beyond the rows and gaps
|
|
// it spans. Prefer unstyled rows; when they already have content,
|
|
// distribute proportionally, matching the table row-group algorithm.
|
|
for &(_, row, span, natural) in &cells {
|
|
if span <= 1 {
|
|
continue;
|
|
}
|
|
let end = row + span;
|
|
let current: f32 = row_heights[row..end].iter().sum::<f32>()
|
|
+ row_gap * span.saturating_sub(1) as f32;
|
|
let extra = natural - current;
|
|
if extra <= 0.0 {
|
|
continue;
|
|
}
|
|
let mut targets: Vec<usize> = (row..end)
|
|
.filter(|&index| row_minimums[index].is_none())
|
|
.collect();
|
|
if targets.is_empty() {
|
|
targets = (row..end).collect();
|
|
}
|
|
let weight: f32 = targets.iter().map(|&index| row_heights[index]).sum();
|
|
for &index in &targets {
|
|
let share = if weight > 0.0 {
|
|
extra * row_heights[index] / weight
|
|
} else {
|
|
extra / targets.len() as f32
|
|
};
|
|
row_heights[index] += share;
|
|
}
|
|
}
|
|
|
|
// A definite table height distributes surplus into rows; an auto
|
|
// table must not preserve stale free space from the generic grid pass.
|
|
if table_has_height {
|
|
if let Ok(table_layout) = taffy_tree.layout(table_node) {
|
|
let track_target = (table_layout.size.height
|
|
- table_layout.padding.top
|
|
- table_layout.padding.bottom
|
|
- table_layout.border.top
|
|
- table_layout.border.bottom
|
|
- row_gap * nrows.saturating_sub(1) as f32)
|
|
.max(0.0);
|
|
let current: f32 = row_heights.iter().sum();
|
|
let extra = track_target - current;
|
|
if extra > 0.0 {
|
|
let mut targets: Vec<usize> = (0..nrows)
|
|
.filter(|&index| row_minimums[index].is_none())
|
|
.collect();
|
|
if targets.is_empty() {
|
|
targets = (0..nrows).collect();
|
|
}
|
|
let weight: f32 =
|
|
targets.iter().map(|&index| row_heights[index]).sum();
|
|
for &index in &targets {
|
|
let share = if weight > 0.0 {
|
|
extra * row_heights[index] / weight
|
|
} else {
|
|
extra / targets.len() as f32
|
|
};
|
|
row_heights[index] += share;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(current) = taffy_tree.style(table_node) {
|
|
let mut fixed = current.clone();
|
|
fixed.grid_template_rows = row_heights
|
|
.iter()
|
|
.map(|height| {
|
|
taffy::GridTemplateComponent::Single(taffy::MinMax {
|
|
min: taffy::MinTrackSizingFunction::length(*height),
|
|
max: taffy::MaxTrackSizingFunction::length(*height),
|
|
})
|
|
})
|
|
.collect();
|
|
if taffy_tree.set_style(table_node, fixed).is_ok() {
|
|
changed = true;
|
|
}
|
|
}
|
|
// A CSS height on a cell is a row minimum, not a smaller final cell
|
|
// box. Once the tracks are pinned, restore auto block-size so every
|
|
// cell stretches to its complete (possibly spanned) grid area.
|
|
for (cell, _, _, _) in cells {
|
|
if let Ok(current) = taffy_tree.style(cell) {
|
|
let mut stretched = current.clone();
|
|
stretched.size.height = taffy::Dimension::auto();
|
|
let _ = taffy_tree.set_style(cell, stretched);
|
|
}
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Balance the atomic child boxes of each CSS multi-column container.
|
|
///
|
|
/// Gecko's multicol reflow brackets a known-infeasible and known-feasible
|
|
/// column block-size, then repeatedly reflows at a tighter candidate until it
|
|
/// finds the smallest feasible height. We use the same invariant over the
|
|
/// already measured direct child boxes: a candidate is feasible when a
|
|
/// source-ordered greedy fill needs no more than the requested column count.
|
|
/// This is O(24 * children) per multicol container and does not reshape or
|
|
/// rebuild descendants during the search.
|
|
fn apply_multicol_balance(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
multicol: &[MulticolBuild],
|
|
) -> bool {
|
|
let mut changed = false;
|
|
for set in multicol {
|
|
let column_count = set.columns.len();
|
|
if column_count < 2 || set.children.is_empty() {
|
|
continue;
|
|
}
|
|
let weights: Vec<f32> = set
|
|
.children
|
|
.iter()
|
|
.map(|child| {
|
|
taffy_tree.layout(*child).map_or(0.0, |layout| {
|
|
(layout.size.height + layout.margin.top + layout.margin.bottom)
|
|
.max(0.0)
|
|
})
|
|
})
|
|
.collect();
|
|
let groups = column_count.min(weights.len());
|
|
let max_weight = weights.iter().copied().fold(0.0f32, f32::max);
|
|
let total: f32 = weights.iter().sum();
|
|
let mut low = max_weight;
|
|
let mut high = total.max(low);
|
|
if high > 0.0 {
|
|
for _ in 0..24 {
|
|
let candidate = (low + high) * 0.5;
|
|
let mut used = 1usize;
|
|
let mut current = 0.0f32;
|
|
for weight in &weights {
|
|
if current > 0.0 && current + *weight > candidate {
|
|
used += 1;
|
|
current = *weight;
|
|
} else {
|
|
current += *weight;
|
|
}
|
|
}
|
|
if used <= groups {
|
|
high = candidate;
|
|
} else {
|
|
low = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pack from the end at the smallest feasible height. This leaves the
|
|
// first columns filled first (column-major source order) while still
|
|
// guaranteeing one item for every non-empty column.
|
|
let mut ranges = Vec::with_capacity(groups);
|
|
let mut end = weights.len();
|
|
for remaining_groups in (1..=groups).rev() {
|
|
let earliest = remaining_groups - 1;
|
|
let mut start = end;
|
|
let mut used = 0.0f32;
|
|
while start > earliest {
|
|
let next = weights[start - 1];
|
|
if start < end && used + next > high + 0.001 {
|
|
break;
|
|
}
|
|
start -= 1;
|
|
used += next;
|
|
}
|
|
ranges.push(start..end);
|
|
end = start;
|
|
}
|
|
ranges.reverse();
|
|
|
|
for (index, column) in set.columns.iter().enumerate() {
|
|
let children = ranges
|
|
.get(index)
|
|
.map(|range| &set.children[range.clone()])
|
|
.unwrap_or(&[]);
|
|
if taffy_tree.set_children(*column, children).is_ok() {
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Table-cell block alignment is a post-row-sizing operation. Feeding
|
|
/// `justify-content:center/end` into a cell while grid is still computing its
|
|
/// auto row tracks lets alignment offsets contaminate the cell's intrinsic
|
|
/// block-size (a nested 24px table can incorrectly demand a 29px row). Measure
|
|
/// cells from block-start first, freeze each final cell border-box height, then
|
|
/// enable middle/bottom alignment for the final layout pass.
|
|
fn apply_table_cell_block_alignment(
|
|
tree: &DomTree,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> bool {
|
|
let mut changed = false;
|
|
for (&node, &dom_id) in id_map {
|
|
let Some(style) = styles.get(&dom_id) else {
|
|
continue;
|
|
};
|
|
if !style.internal_flex_container
|
|
|| !matches!(
|
|
style.vertical_align,
|
|
Some(crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom)
|
|
)
|
|
{
|
|
continue;
|
|
}
|
|
let is_cell = tree.get_node(dom_id).map_or(false, |dom_node| {
|
|
dom_node.as_element().map_or(false, |element| {
|
|
matches!(element.local.as_ref(), "td" | "th")
|
|
})
|
|
});
|
|
if !is_cell || taffy_tree.children(node).map_or(true, |children| children.is_empty()) {
|
|
// Pure-text cells are aligned after shaping in the text finalize
|
|
// path, where their actual line box height is available.
|
|
continue;
|
|
}
|
|
let (Ok(current), Ok(layout)) = (taffy_tree.style(node), taffy_tree.layout(node)) else {
|
|
continue;
|
|
};
|
|
if current.display != taffy::style::Display::Flex {
|
|
continue;
|
|
}
|
|
let mut aligned = current.clone();
|
|
let border_padding = layout.border.top
|
|
+ layout.border.bottom
|
|
+ layout.padding.top
|
|
+ layout.padding.bottom;
|
|
let declared_height = if aligned.box_sizing == taffy::BoxSizing::ContentBox {
|
|
(layout.size.height - border_padding).max(0.0)
|
|
} else {
|
|
layout.size.height
|
|
};
|
|
aligned.size.height = length(declared_height);
|
|
let is_middle = style.vertical_align == Some(crate::VerticalAlign::Middle);
|
|
if aligned.flex_direction == taffy::FlexDirection::Column {
|
|
aligned.justify_content = Some(if is_middle {
|
|
taffy::JustifyContent::CENTER
|
|
} else {
|
|
taffy::JustifyContent::FLEX_END
|
|
});
|
|
} else {
|
|
aligned.align_content = Some(if is_middle {
|
|
taffy::AlignContent::CENTER
|
|
} else {
|
|
taffy::AlignContent::FLEX_END
|
|
});
|
|
}
|
|
if taffy_tree.set_style(node, aligned).is_ok() {
|
|
changed = true;
|
|
}
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Repair Taffy 0.12's intrinsic main-size calculation for column flexboxes
|
|
/// containing a negative main-axis margin.
|
|
///
|
|
/// In the max-content path Taffy feeds the margin-adjusted contribution into
|
|
/// its flex-fraction calculation. A negative margin on a shrink-disabled item
|
|
/// can therefore subtract the item's entire flex basis (or many multiples of
|
|
/// it) from the container: a `margin-top:-15px` child with a 100px basis can
|
|
/// collapse an otherwise auto-height flex column to zero. Final item sizes and
|
|
/// placement are still correct; only the container's automatic block size is
|
|
/// corrupted.
|
|
///
|
|
/// CSS defines that automatic size from the flex items' outer main sizes. Use
|
|
/// the already-resolved final item margins and border-box sizes to freeze that
|
|
/// intrinsic content height, then let the caller run Taffy again so ordinary
|
|
/// min/max sizing and ancestor layout consume the corrected contribution.
|
|
fn repair_intrinsic_column_flex_negative_margins(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> bool {
|
|
let mut repairs = Vec::new();
|
|
|
|
for (&node, &dom_id) in id_map {
|
|
let Some(style) = styles.get(&dom_id) else {
|
|
continue;
|
|
};
|
|
if style.display != crate::Display::Flex
|
|
|| style.internal_flex_container
|
|
|| style.height != crate::Dimension::Auto
|
|
|| effective_container_type(style) == crate::ContainerType::Size
|
|
|| !matches!(
|
|
style.flex_direction,
|
|
Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse)
|
|
)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let Ok(children) = taffy_tree.children(node) else {
|
|
continue;
|
|
};
|
|
let mut item_count = 0usize;
|
|
let mut content_height = 0.0f32;
|
|
let mut has_negative_main_margin = false;
|
|
for child in children {
|
|
let (Ok(child_style), Ok(child_layout)) =
|
|
(taffy_tree.style(child), taffy_tree.layout(child))
|
|
else {
|
|
continue;
|
|
};
|
|
if child_style.display == taffy::style::Display::None
|
|
|| child_style.position == taffy::style::Position::Absolute
|
|
{
|
|
continue;
|
|
}
|
|
let margin = child_layout.margin.top + child_layout.margin.bottom;
|
|
has_negative_main_margin |=
|
|
child_layout.margin.top < 0.0 || child_layout.margin.bottom < 0.0;
|
|
content_height += child_layout.size.height + margin;
|
|
item_count += 1;
|
|
}
|
|
if !has_negative_main_margin || item_count == 0 {
|
|
continue;
|
|
}
|
|
content_height +=
|
|
style.row_gap.unwrap_or(0.0) * item_count.saturating_sub(1) as f32;
|
|
content_height = content_height.max(0.0);
|
|
|
|
let Ok(layout) = taffy_tree.layout(node) else {
|
|
continue;
|
|
};
|
|
let outer_height = content_height
|
|
+ layout.padding.top
|
|
+ layout.padding.bottom
|
|
+ layout.border.top
|
|
+ layout.border.bottom;
|
|
if (layout.size.height - outer_height).abs() < 0.01 {
|
|
continue;
|
|
}
|
|
let declared_height = if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
content_height
|
|
} else {
|
|
outer_height
|
|
};
|
|
repairs.push((node, declared_height));
|
|
}
|
|
|
|
let mut changed = false;
|
|
for (node, height) in repairs {
|
|
let Ok(current) = taffy_tree.style(node) else {
|
|
continue;
|
|
};
|
|
let mut repaired = current.clone();
|
|
repaired.size.height = taffy::Dimension::length(height);
|
|
changed |= taffy_tree.set_style(node, repaired).is_ok();
|
|
}
|
|
changed
|
|
}
|
|
|
|
fn resolve_static_positions_and_reparent(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
candidates: &[StaticPositionCandidate],
|
|
) {
|
|
// Harvest every placeholder coordinate before mutating parent links.
|
|
// Otherwise reparenting an outer candidate changes the global-origin walk
|
|
// for a nested candidate while both still contain preliminary-pass layout
|
|
// coordinates.
|
|
let mut resolved = Vec::with_capacity(candidates.len());
|
|
for candidate in candidates {
|
|
let Some(child_origin) = taffy_global_origin(taffy_tree, candidate.child) else { continue };
|
|
let Some(target_origin) = taffy_global_origin(taffy_tree, candidate.target) else { continue };
|
|
let Ok(child_layout) = taffy_tree.layout(candidate.child) else { continue };
|
|
let child_margin = child_layout.margin;
|
|
let Ok(target_layout) = taffy_tree.layout(candidate.target) else { continue };
|
|
let target_border = target_layout.border;
|
|
let Ok(current_style) = taffy_tree.style(candidate.child) else { continue };
|
|
let mut style = current_style.clone();
|
|
|
|
if candidate.inline_axis {
|
|
style.inset.left =
|
|
length(child_origin.0 - target_origin.0 - target_border.left - child_margin.left);
|
|
}
|
|
if candidate.block_axis {
|
|
style.inset.top =
|
|
length(child_origin.1 - target_origin.1 - target_border.top - child_margin.top);
|
|
}
|
|
resolved.push((*candidate, style));
|
|
}
|
|
|
|
for (candidate, style) in resolved {
|
|
let Some(current) = taffy_tree.parent(candidate.child) else { continue };
|
|
if taffy_tree.remove_child(current, candidate.child).is_err() {
|
|
continue;
|
|
}
|
|
if taffy_tree.add_child(candidate.target, candidate.child).is_err() {
|
|
let _ = taffy_tree.add_child(current, candidate.child);
|
|
continue;
|
|
}
|
|
if taffy_tree.set_style(candidate.child, style).is_err() {
|
|
let _ = taffy_tree.remove_child(candidate.target, candidate.child);
|
|
let _ = taffy_tree.add_child(current, candidate.child);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Does `id` have any direct child that is inline-level (a non-whitespace
|
|
/// text node, or an element whose resolved display is `Inline`)? Used to
|
|
/// decide whether a block container needs the flex-row-wrap approximation of
|
|
/// an inline formatting context.
|
|
fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
|
|
tree.children(id).into_iter().any(|cid| {
|
|
let Some(node) = tree.get_node(cid) else { return false };
|
|
match &node.data {
|
|
obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(),
|
|
_ => styles
|
|
.get(&cid)
|
|
.map(|s| {
|
|
// A display:contents wrapper is transparent: whether it
|
|
// reads as inline content depends on what it splices in.
|
|
if s.display_contents && s.display != crate::Display::None {
|
|
has_inline_content(tree, cid, styles)
|
|
} else {
|
|
// Out-of-flow boxes (absolutely positioned, floated)
|
|
// are not inline content: an inline that is the sole
|
|
// child of a hero wrapper must not drag the wrapper
|
|
// into the inline-formatting path.
|
|
crate::is_inline_level_box(s)
|
|
&& !matches!(s.position, Some(taffy::Position::Absolute))
|
|
&& s.float.is_none()
|
|
}
|
|
})
|
|
.unwrap_or(false),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Blockify the generated layout children of a flex/grid container. A
|
|
/// display:contents element is transparent, so its first boxed descendants
|
|
/// are the items whose outer display changes.
|
|
fn blockify_layout_children(
|
|
tree: &DomTree,
|
|
parent: NodeId,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
) {
|
|
for child in tree.children(parent) {
|
|
let transparent = styles
|
|
.get(&child)
|
|
.is_some_and(|style| style.display_contents && style.display != crate::Display::None);
|
|
if transparent {
|
|
blockify_layout_children(tree, child, styles);
|
|
} else if let Some(style) = styles.get_mut(&child) {
|
|
crate::blockify_outer_display(style);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build whichever of an element or a text node `id` is, returning every
|
|
/// taffy node it produced (usually one, but a text node fans out to one leaf
|
|
/// per word; see `build_text_words`). Callers collecting a container's
|
|
/// children should use this instead of calling `build` directly, so text
|
|
/// nodes flatten into the same child list as their sibling elements rather
|
|
/// than being skipped or nested a level deeper (either of which would break
|
|
/// wrapping: the whole point is for words from different text nodes and
|
|
/// inline elements to sit as flat, interleaved siblings that a flex-wrap row
|
|
/// can break between anywhere).
|
|
fn build_any(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let is_text = tree
|
|
.get_node(id)
|
|
.map(|n| matches!(n.data, obscura_dom::tree::NodeData::Text { .. }))
|
|
.unwrap_or(false);
|
|
if is_text {
|
|
return build_text_words(tree, id, taffy_tree, styles, words);
|
|
}
|
|
// `display: contents` removes the element's own box; its children lay out as
|
|
// if they were direct children of its parent (CSS Display 3). Splice them
|
|
// into the caller's child list (same trick as inline wrappers below).
|
|
// Without this the wrapper became a normal auto-sized box (news-site card
|
|
// anchors are `<a style="display:contents">` around the image), which
|
|
// collapsed to zero area and blanked the image inside (bbc.com/news hero,
|
|
// theguardian.com cards). `display:none` still wins.
|
|
let splices_children = styles
|
|
.get(&id)
|
|
.map(|s| s.display_contents && s.display != crate::Display::None)
|
|
.unwrap_or(false);
|
|
if splices_children {
|
|
return tree
|
|
.children(id)
|
|
.into_iter()
|
|
.flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles))
|
|
.collect();
|
|
}
|
|
if is_flattenable_inline(tree, id, styles) {
|
|
// A plain inline wrapper (`<span>`, `<a>`, `<b>`, ...) around text
|
|
// with no box appearance of its own: giving it its own flex-wrap
|
|
// container, like every other element gets, would make *it* the
|
|
// inline-formatting context instead of the enclosing block, which is
|
|
// backwards. Real inline boxes do not wrap independently; the words
|
|
// they contain wrap as part of the single line-breaking process run
|
|
// by their nearest block ancestor. Concretely, a taffy flex-wrap
|
|
// container with an auto width sizes itself from a "measure" pass
|
|
// that does not yet know the final available width, so it collapses
|
|
// toward a min-content (one word wide) guess — visible on Wikipedia's
|
|
// "53 languages" label, which wrapped to "53" / "languages" on two
|
|
// lines purely because the wrapping `<span>` around that text was
|
|
// itself an independent auto-width wrap container. Flattening the
|
|
// wrapper's children into the caller's list (same trick already used
|
|
// for text nodes in `build_text_words`) fixes this at the root: the
|
|
// words become flat siblings that wrap only at the real block level.
|
|
return tree
|
|
.children(id)
|
|
.into_iter()
|
|
.flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles))
|
|
.collect();
|
|
}
|
|
let Some(inner) = build(tree, id, taffy_tree, id_map, words, engine, ifc_items, styles) else {
|
|
return Vec::new();
|
|
};
|
|
let inline_align = styles.get(&id).and_then(|style| {
|
|
if !crate::is_inline_level_box(style) {
|
|
return None;
|
|
}
|
|
match style.vertical_align {
|
|
Some(crate::VerticalAlign::Middle) => Some(taffy::AlignSelf::CENTER),
|
|
Some(crate::VerticalAlign::Bottom) => Some(taffy::AlignSelf::FLEX_END),
|
|
Some(crate::VerticalAlign::Top) => Some(taffy::AlignSelf::FLEX_START),
|
|
None => None,
|
|
}
|
|
});
|
|
let needs_outer = styles.get(&id).is_some_and(|style| {
|
|
style.is_inline_block
|
|
&& matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|
|
&& matches!(style.width, crate::Dimension::Auto)
|
|
&& style.size_expressions[0].is_none()
|
|
});
|
|
if !needs_outer {
|
|
if let Some(align_self) = inline_align {
|
|
if let Ok(current) = taffy_tree.style(inner) {
|
|
let mut adjusted = current.clone();
|
|
adjusted.align_self = Some(align_self);
|
|
let _ = taffy_tree.set_style(inner, adjusted);
|
|
}
|
|
}
|
|
return vec![inner];
|
|
}
|
|
|
|
// Taffy stores only the inner display mode. If an inline-flex/grid node is
|
|
// handed directly to block or flex layout, its auto width is stretched to
|
|
// the available width. A transparent atomic outer node supplies the
|
|
// shrink-wrapping inline participation while the authored node retains its
|
|
// real Flex/Grid layout and remains the DOM geometry/paint owner.
|
|
let transfers_inline_constraint = styles.get(&id).is_some_and(|style| {
|
|
style.min_width != crate::Dimension::Auto
|
|
|| style.max_width != crate::Dimension::Auto
|
|
|| style.size_expressions[2].is_some()
|
|
|| style.size_expressions[4].is_some()
|
|
});
|
|
let (outer_min_width, outer_max_width) = if transfers_inline_constraint {
|
|
taffy_tree
|
|
.style(inner)
|
|
.map(|inner_style| (inner_style.min_size.width, inner_style.max_size.width))
|
|
.unwrap_or((taffy::Dimension::auto(), taffy::Dimension::auto()))
|
|
} else {
|
|
(taffy::Dimension::auto(), taffy::Dimension::auto())
|
|
};
|
|
if transfers_inline_constraint {
|
|
if let Ok(current) = taffy_tree.style(inner) {
|
|
let mut adjusted = current.clone();
|
|
adjusted.size.width = taffy::Dimension::percent(1.0);
|
|
adjusted.min_size.width = taffy::Dimension::auto();
|
|
adjusted.max_size.width = taffy::Dimension::auto();
|
|
let _ = taffy_tree.set_style(inner, adjusted);
|
|
}
|
|
}
|
|
let outer_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
align_self: inline_align,
|
|
min_size: taffy::Size {
|
|
width: outer_min_width,
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
max_size: taffy::Size {
|
|
width: outer_max_width,
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
match taffy_tree.new_with_children(outer_style, &[inner]) {
|
|
Ok(outer) => vec![outer],
|
|
Err(_) => vec![inner],
|
|
}
|
|
}
|
|
|
|
/// Build the direct children of a genuine flex/grid container.
|
|
///
|
|
/// CSS wraps every contiguous run of in-flow text in one anonymous flex/grid
|
|
/// item whose contents form an inline formatting context. Splitting a text
|
|
/// node into one taffy item per word is only valid inside our flex-wrap IFC
|
|
/// stand-in; doing it at this outer level makes `flex-wrap:nowrap` lay an
|
|
/// entire paragraph on one line (MDN's contributor quote). Fold text runs to
|
|
/// one measured/shaped anonymous item and leave authored element children as
|
|
/// their own flex/grid items.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_flex_grid_children(
|
|
tree: &DomTree,
|
|
parent: NodeId,
|
|
dom_children: &[NodeId],
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let mut children = Vec::new();
|
|
let mut index = 0;
|
|
while index < dom_children.len() {
|
|
let is_text = tree.get_node(dom_children[index]).map_or(false, |node| {
|
|
matches!(node.data, obscura_dom::tree::NodeData::Text { .. })
|
|
});
|
|
if !is_text {
|
|
children.extend(build_any(
|
|
tree,
|
|
dom_children[index],
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
let start = index;
|
|
while index < dom_children.len()
|
|
&& tree.get_node(dom_children[index]).map_or(false, |node| {
|
|
matches!(node.data, obscura_dom::tree::NodeData::Text { .. })
|
|
})
|
|
{
|
|
index += 1;
|
|
}
|
|
let run = &dom_children[start..index];
|
|
if let Some(item) = engine.try_build_run(tree, parent, run, styles) {
|
|
let style = taffy::Style {
|
|
display: taffy::style::Display::Block,
|
|
..Default::default()
|
|
};
|
|
if let Ok(leaf) = taffy_tree.new_leaf_with_context(style, item) {
|
|
ifc_items.runs.entry(parent).or_default().push(item);
|
|
children.push(leaf);
|
|
continue;
|
|
}
|
|
}
|
|
for &text in run {
|
|
children.extend(build_any(
|
|
tree,
|
|
text,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
));
|
|
}
|
|
}
|
|
children
|
|
}
|
|
|
|
/// Is `id` a `display: inline` element with no box appearance or sizing of
|
|
/// its own — safe to flatten into its parent's child list instead of giving
|
|
/// it an independent (and, for wrapping auto-width containers, buggy) flex
|
|
/// context? Covers the dominant real-world case: `<a>`/`<span>`/`<b>`/etc.
|
|
/// wrapping plain text with no inline styling.
|
|
fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
|
|
let Some(node) = tree.get_node(id) else { return false };
|
|
if node.as_element().is_none() {
|
|
return false;
|
|
}
|
|
let Some(style) = styles.get(&id) else { return false };
|
|
style.display == crate::Display::Inline
|
|
&& !style.is_inline_block
|
|
&& style.before_pseudo.is_none()
|
|
&& style.after_pseudo.is_none()
|
|
&& style.background_color.is_none()
|
|
&& style.background_image.is_none()
|
|
&& style.mask_image.is_none()
|
|
&& style.border == crate::Edges::default()
|
|
&& style.width == crate::Dimension::Auto
|
|
&& style.height == crate::Dimension::Auto
|
|
&& style.position.is_none()
|
|
&& !style.overflow_hidden
|
|
&& style.float.is_none()
|
|
}
|
|
|
|
/// Split a text node into one taffy leaf per word (a whitespace-delimited
|
|
/// token, each keeping a single trailing space so adjacent words stay
|
|
/// visually separated), so it can wrap across several lines within its
|
|
/// container instead of being one indivisible box. A single leaf per whole
|
|
/// text node cannot wrap internally: flex-wrap only ever breaks *between*
|
|
/// items, so a long run of plain text with no inline elements breaking it up
|
|
/// (a very common shape: prose without a link for several sentences) would
|
|
/// either fit as one giant item or overflow straight past the container's
|
|
/// edge, never wrapping onto a new line the way real text does.
|
|
fn build_text_words(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let Some(node) = tree.get_node(id) else { return Vec::new() };
|
|
let obscura_dom::tree::NodeData::Text { contents } = &node.data else { return Vec::new() };
|
|
|
|
let mut display_text = String::new();
|
|
let mut in_space = false;
|
|
for c in contents.chars() {
|
|
if c.is_whitespace() {
|
|
if !in_space {
|
|
display_text.push(' ');
|
|
in_space = true;
|
|
}
|
|
} else {
|
|
display_text.push(c);
|
|
in_space = false;
|
|
}
|
|
}
|
|
|
|
let mut fsize = 16.0;
|
|
let mut is_bold = false;
|
|
let mut family = None;
|
|
let mut line_height = fsize * 1.2;
|
|
let mut transform = crate::TextTransform::None;
|
|
let mut letter_spacing = 0.0;
|
|
if let Some(parent_id) = node.parent {
|
|
if let Some(p_style) = styles.get(&parent_id) {
|
|
fsize = p_style.font_size.unwrap_or(16.0);
|
|
is_bold = crate::style::used_font_weight(p_style) >= 600;
|
|
family = p_style.font_family.as_deref();
|
|
line_height = crate::inline::used_line_height(p_style);
|
|
transform = p_style
|
|
.text_transform
|
|
.unwrap_or(crate::TextTransform::None);
|
|
letter_spacing = p_style.letter_spacing.unwrap_or(0.0);
|
|
}
|
|
}
|
|
display_text = transform_word_leaf_text(&display_text, transform);
|
|
|
|
build_word_leaves(
|
|
id,
|
|
&display_text,
|
|
fsize,
|
|
line_height,
|
|
is_bold,
|
|
family,
|
|
letter_spacing,
|
|
taffy_tree,
|
|
words,
|
|
)
|
|
}
|
|
|
|
fn transform_word_leaf_text(text: &str, transform: crate::TextTransform) -> String {
|
|
match transform {
|
|
crate::TextTransform::None => text.to_string(),
|
|
crate::TextTransform::Uppercase => text.chars().flat_map(char::to_uppercase).collect(),
|
|
crate::TextTransform::Lowercase => text.chars().flat_map(char::to_lowercase).collect(),
|
|
crate::TextTransform::Capitalize => {
|
|
let mut at_word_start = true;
|
|
let mut out = String::with_capacity(text.len());
|
|
for ch in text.chars() {
|
|
if ch.is_whitespace() {
|
|
at_word_start = true;
|
|
out.push(ch);
|
|
} else if at_word_start {
|
|
out.extend(ch.to_uppercase());
|
|
at_word_start = false;
|
|
} else {
|
|
out.push(ch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Split `text` into one taffy leaf per word and register each against
|
|
/// `source_id` in `words`. Shared by `build_text_words` (a real DOM text
|
|
/// node) and `build_pseudo_content` (a `::before`/`::after` literal, which
|
|
/// has no text node of its own — `source_id` is the host element instead).
|
|
fn build_word_leaves(
|
|
source_id: NodeId,
|
|
text: &str,
|
|
fsize: f32,
|
|
line_height: f32,
|
|
is_bold: bool,
|
|
family: Option<&str>,
|
|
letter_spacing: f32,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
) -> Vec<taffy::NodeId> {
|
|
tokenize_with_spaces(text)
|
|
.into_iter()
|
|
.filter_map(|token| {
|
|
let width = text_width(&token, fsize, is_bold, family, letter_spacing);
|
|
// A pure-whitespace token is HTML source formatting or a bare
|
|
// inter-element space; it keeps its (small) width so adjacent
|
|
// inline content stays visually separated, but contributes no
|
|
// height, so it never adds a spurious blank row when it lands
|
|
// between block-level siblings (e.g. formatting whitespace
|
|
// around a run of now-collapsed, display:none list items).
|
|
let height = if token.trim().is_empty() {
|
|
0.0
|
|
} else {
|
|
line_height.max(0.0)
|
|
};
|
|
let taffy_style = taffy::Style {
|
|
size: taffy::Size { width: taffy::Dimension::length(width), height: taffy::Dimension::length(height) },
|
|
..Default::default()
|
|
};
|
|
let taffy_id = taffy_tree.new_leaf(taffy_style).ok()?;
|
|
words.insert(taffy_id, (source_id, token));
|
|
Some(taffy_id)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the word leaves for a `::before`/`::after` literal `content`,
|
|
/// registered against the host element's own id (there is no real DOM text
|
|
/// node backing generated content, so its geometry is reported under the
|
|
/// element itself in `DomLayout::text_runs`, and painted the same way a real
|
|
/// text node's words are).
|
|
fn build_pseudo_content(
|
|
id: NodeId,
|
|
content: &str,
|
|
style: &crate::LayoutStyle,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let fsize = style.font_size.unwrap_or(16.0);
|
|
let is_bold = crate::style::used_font_weight(style) >= 600;
|
|
build_word_leaves(
|
|
id,
|
|
content,
|
|
fsize,
|
|
crate::inline::used_line_height(style),
|
|
is_bold,
|
|
style.font_family.as_deref(),
|
|
style.letter_spacing.unwrap_or(0.0),
|
|
taffy_tree,
|
|
words,
|
|
)
|
|
}
|
|
|
|
fn pseudo_requires_generated_box(style: &crate::LayoutStyle, content: Option<&str>) -> bool {
|
|
content == Some("")
|
|
|| style.content_image.is_some()
|
|
|| style.display != crate::Display::Inline
|
|
|| style.is_inline_block
|
|
|| style.position.is_some()
|
|
|| style.width != crate::Dimension::Auto
|
|
|| style.height != crate::Dimension::Auto
|
|
|| style.min_width != crate::Dimension::Auto
|
|
|| style.min_height != crate::Dimension::Auto
|
|
|| style.max_width != crate::Dimension::Auto
|
|
|| style.max_height != crate::Dimension::Auto
|
|
|| style.margin != crate::Edges::default()
|
|
|| style.margin_auto.iter().any(|value| *value)
|
|
|| style.padding != crate::Edges::default()
|
|
|| style.padding_percent.iter().any(|value| value.is_some())
|
|
|| style.border != crate::Edges::default()
|
|
|| style.background_color.is_some()
|
|
|| style.background_gradient.is_some()
|
|
|| style.background_radial_gradient.is_some()
|
|
|| style.background_conic_gradient.is_some()
|
|
|| style.background_image.is_some()
|
|
|| style.mask_image.is_some()
|
|
|| style.box_shadow.is_some()
|
|
|| !style.border_radius.is_zero()
|
|
|| style.overflow_hidden
|
|
|| style.transform_translate.is_some()
|
|
|| style.transform_scale.is_some()
|
|
}
|
|
|
|
fn has_in_flow_generated_pseudo(pseudo: Option<&crate::LayoutStyle>) -> bool {
|
|
pseudo.map_or(false, |pseudo| {
|
|
pseudo.display != crate::Display::None
|
|
&& !matches!(pseudo.position, Some(taffy::Position::Absolute))
|
|
})
|
|
}
|
|
|
|
/// Build one in-flow pseudo as either the existing zero-overhead generated
|
|
/// text leaves or a real anonymous box. The latter is used only when the
|
|
/// pseudo's own box model can affect geometry/paint; ordinary `content:"x"`
|
|
/// remains part of the surrounding inline run and does not allocate an extra
|
|
/// container.
|
|
fn build_in_flow_pseudo(
|
|
host: NodeId,
|
|
kind: GeneratedBoxKind,
|
|
pseudo: Option<&crate::LayoutStyle>,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
ifc_items: &mut IfcRegistry,
|
|
) -> Option<(Vec<taffy::NodeId>, bool)> {
|
|
let pseudo = pseudo?;
|
|
if matches!(pseudo.position, Some(taffy::Position::Absolute))
|
|
|| pseudo.display == crate::Display::None
|
|
{
|
|
return None;
|
|
}
|
|
let content = pseudo.before_content.as_deref();
|
|
if !pseudo_requires_generated_box(pseudo, content) {
|
|
let leaves = build_pseudo_content(host, content?, pseudo, taffy_tree, words);
|
|
return (!leaves.is_empty()).then_some((leaves, false));
|
|
}
|
|
|
|
let children = content
|
|
.filter(|text| !text.is_empty())
|
|
.map(|text| build_pseudo_content(host, text, pseudo, taffy_tree, words))
|
|
.unwrap_or_default();
|
|
let mut taffy_style = to_taffy_style(pseudo);
|
|
// A block pseudo's outer participation is block-level, but its generated
|
|
// text still forms an inline formatting context inside it. Taffy has no
|
|
// split outer/inner display representation, so use a wrapping row for the
|
|
// inner text while the parent block still treats this node as one child.
|
|
if pseudo.display == crate::Display::Block && !children.is_empty() {
|
|
taffy_style.display = taffy::style::Display::Flex;
|
|
taffy_style.flex_direction = taffy::FlexDirection::Row;
|
|
taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
|
|
}
|
|
let node = if children.is_empty() {
|
|
taffy_tree.new_leaf(taffy_style).ok()?
|
|
} else {
|
|
taffy_tree.new_with_children(taffy_style, &children).ok()?
|
|
};
|
|
ifc_items
|
|
.generated
|
|
.push(GeneratedBoxBuild { host, kind, node });
|
|
let block_level = pseudo.display != crate::Display::Inline && !pseudo.is_inline_block;
|
|
Some((vec![node], block_level))
|
|
}
|
|
|
|
/// Split already whitespace-collapsed text into tokens, each carrying at most
|
|
/// one trailing space (`"Hello World "` -> `["Hello ", "World "]`), so a
|
|
/// word's own width naturally includes the gap to the next one.
|
|
fn tokenize_with_spaces(text: &str) -> Vec<String> {
|
|
let mut tokens = Vec::new();
|
|
let mut cur = String::new();
|
|
for c in text.chars() {
|
|
cur.push(c);
|
|
if c == ' ' {
|
|
tokens.push(std::mem::take(&mut cur));
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
tokens.push(cur);
|
|
}
|
|
tokens
|
|
}
|
|
|
|
/// Give each `<tr>`/`<tbody>`/`<thead>`/`<tfoot>` its CSS table-grid band. In
|
|
/// the grid table model these wrappers are not taffy boxes, so without this
|
|
/// their backgrounds, borders, and DOM geometry would disappear.
|
|
///
|
|
/// A row's inline extent includes all cells that start in it, but its block
|
|
/// extent must ignore the portion of a `rowspan` that continues through later
|
|
/// rows. Nested-table cells must not participate at all. Sections are then the
|
|
/// union of their already-synthesized direct rows.
|
|
fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap<NodeId, Rect>) {
|
|
let mut rows = Vec::new();
|
|
let mut sections = Vec::new();
|
|
let mut table_inline: HashMap<NodeId, Rect> = HashMap::new();
|
|
for id in tree.descendants(tree.document()) {
|
|
let local = match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) {
|
|
Some(l) => l,
|
|
None => continue,
|
|
};
|
|
match local.as_str() {
|
|
"tr" => rows.push(id),
|
|
"tbody" | "thead" | "tfoot" => sections.push(id),
|
|
"td" | "th" => {
|
|
let mut ancestor = tree.get_node(id).and_then(|node| node.parent);
|
|
while let Some(parent) = ancestor {
|
|
let is_table = tree.get_node(parent).map_or(false, |node| {
|
|
node.as_element()
|
|
.map_or(false, |element| element.local.as_ref() == "table")
|
|
});
|
|
if is_table {
|
|
if let Some(rect) = rects.get(&id) {
|
|
table_inline
|
|
.entry(parent)
|
|
.and_modify(|current| *current = current.union(rect))
|
|
.or_insert(*rect);
|
|
}
|
|
break;
|
|
}
|
|
ancestor = tree.get_node(parent).and_then(|node| node.parent);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
for id in rows {
|
|
if rects.contains_key(&id) {
|
|
continue;
|
|
}
|
|
let mut inline: Option<Rect> = None;
|
|
let mut block: Option<Rect> = None;
|
|
for cell in tree.children(id) {
|
|
let is_cell = tree
|
|
.get_node(cell)
|
|
.and_then(|n| n.as_element().map(|e| matches!(e.local.as_ref(), "td" | "th")))
|
|
.unwrap_or(false);
|
|
if !is_cell {
|
|
continue;
|
|
}
|
|
let Some(r) = rects.get(&cell) else {
|
|
continue;
|
|
};
|
|
inline = Some(match inline {
|
|
Some(a) => a.union(r),
|
|
None => *r,
|
|
});
|
|
let spans_one_row = tree
|
|
.get_node(cell)
|
|
.and_then(|node| {
|
|
node.get_attribute("rowspan")
|
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
|
})
|
|
.map_or(true, |span| span == 1);
|
|
if spans_one_row {
|
|
block = Some(match block {
|
|
Some(a) => a.union(r),
|
|
None => *r,
|
|
});
|
|
}
|
|
}
|
|
if let Some(mut inline) = inline {
|
|
let mut ancestor = tree.get_node(id).and_then(|node| node.parent);
|
|
while let Some(parent) = ancestor {
|
|
if let Some(table_band) = table_inline.get(&parent) {
|
|
inline.x = table_band.x;
|
|
inline.width = table_band.width;
|
|
break;
|
|
}
|
|
ancestor = tree.get_node(parent).and_then(|node| node.parent);
|
|
}
|
|
let block = block.unwrap_or(inline);
|
|
rects.insert(
|
|
id,
|
|
Rect {
|
|
x: inline.x,
|
|
y: block.y,
|
|
width: inline.width,
|
|
height: block.height,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
for id in sections {
|
|
if rects.contains_key(&id) {
|
|
continue;
|
|
}
|
|
let mut section: Option<Rect> = None;
|
|
for row in tree.children(id) {
|
|
let is_row = tree
|
|
.get_node(row)
|
|
.map_or(false, |node| {
|
|
node.as_element()
|
|
.map_or(false, |element| element.local.as_ref() == "tr")
|
|
});
|
|
if !is_row {
|
|
continue;
|
|
}
|
|
if let Some(rect) = rects.get(&row) {
|
|
section = Some(match section {
|
|
Some(current) => current.union(rect),
|
|
None => *rect,
|
|
});
|
|
}
|
|
}
|
|
if let Some(rect) = section {
|
|
rects.insert(id, rect);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collect `<tr>` elements of a table in document order, descending through
|
|
/// `<thead>`/`<tbody>`/`<tfoot>` section wrappers but never into a cell or a
|
|
/// nested `<table>` (whose rows belong to that inner table).
|
|
/// Number of ancestors of `id` up to the document root. Used to order nested
|
|
/// tables deepest-first in the table used-width pass.
|
|
fn dom_depth(tree: &DomTree, id: NodeId) -> usize {
|
|
let mut d = 0usize;
|
|
let mut cur = id;
|
|
while let Some(p) = tree.get_node(cur).and_then(|n| n.parent) {
|
|
d += 1;
|
|
cur = p;
|
|
if d > 4096 {
|
|
break;
|
|
}
|
|
}
|
|
d
|
|
}
|
|
|
|
fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<NodeId>) {
|
|
for cid in tree.children(id) {
|
|
let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string()));
|
|
match local.as_deref() {
|
|
Some("tr") => rows.push(cid),
|
|
Some("thead") | Some("tbody") | Some("tfoot") => collect_table_rows(tree, cid, rows),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Effective separate-border spacing. Collapsed tables contribute no spacing
|
|
/// at either the outer table edges or between tracks.
|
|
fn table_spacing(style: &crate::LayoutStyle) -> (f32, f32) {
|
|
if style.border_collapse.unwrap_or(false) {
|
|
(0.0, 0.0)
|
|
} else {
|
|
style.border_spacing.unwrap_or((0.0, 0.0))
|
|
}
|
|
}
|
|
|
|
/// Horizontal non-track area in the table's border box, excluding the gaps
|
|
/// *between* columns: authored border/padding plus one border-spacing unit at
|
|
/// each outer edge.
|
|
fn table_inline_outer_edges(style: &crate::LayoutStyle) -> f32 {
|
|
let (spacing, _) = table_spacing(style);
|
|
style.border.left
|
|
+ style.border.right
|
|
+ style.padding.left
|
|
+ style.padding.right
|
|
+ spacing * 2.0
|
|
}
|
|
|
|
/// Build a `<table>` as a CSS grid. Modeling the table as a grid is what makes
|
|
/// columns negotiate a shared width across every row (min-content/max-content
|
|
/// track sizing), which the old flex-row-per-`<tr>` stack could not do: each
|
|
/// row sized its cells independently, so columns drifted and never lined up.
|
|
/// Cells (`<td>`/`<th>`) become grid items placed by (row, column) with
|
|
/// colspan/rowspan mapped to grid spans; `<tr>`/`<tbody>`/`<thead>`/`<tfoot>`
|
|
/// do not get their own layout boxes (their backgrounds are not modeled yet).
|
|
/// The grid node is created at width:auto here; its final width is set later in
|
|
/// `layout_dom` by a two-pass intrinsic measurement (see the table-sizing pass)
|
|
/// so the table can grow to fit an unbreakable wide cell the way real tables do.
|
|
/// Returns `None` (falling back to the generic path) if the table has no cells.
|
|
fn build_table(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Option<taffy::NodeId> {
|
|
let style = styles.get(&id)?;
|
|
let mut rows: Vec<NodeId> = Vec::new();
|
|
collect_table_rows(tree, id, &mut rows);
|
|
if rows.is_empty() {
|
|
return None;
|
|
}
|
|
// Bounds so a crafted table cannot exhaust memory or time: `colspan`/
|
|
// `rowspan` and the column count are page-controlled, and the occupancy
|
|
// fill is O(rows x cols) native code that neither the V8 watchdog nor a
|
|
// tokio timeout can interrupt. Real tables never approach these. Rows are
|
|
// capped too, both to bound work and to keep grid line/span indices within
|
|
// taffy's i16/u16 range.
|
|
const MAX_SPAN: usize = 1000;
|
|
const MAX_COLS: usize = 1024;
|
|
const MAX_ROWS: usize = 10000;
|
|
if rows.len() > MAX_ROWS {
|
|
rows.truncate(MAX_ROWS);
|
|
}
|
|
let nrows = rows.len();
|
|
|
|
// Assign every cell a (row, column) with a rowspan-occupancy grid so a cell
|
|
// that spans down pushes later rows' cells past the columns it still covers.
|
|
let span_attr = |cid: NodeId, name: &str| -> usize {
|
|
tree.get_node(cid)
|
|
.and_then(|n| n.get_attribute(name).and_then(|v| v.trim().parse::<usize>().ok()))
|
|
.unwrap_or(1)
|
|
};
|
|
let mut occupied: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
|
|
let mut placed: Vec<(NodeId, usize, usize, usize, usize)> = Vec::new();
|
|
let mut ncols = 0usize;
|
|
for (r, &tr) in rows.iter().enumerate() {
|
|
let mut c = 0usize;
|
|
for cid in tree.children(tr) {
|
|
let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string()));
|
|
if !matches!(local.as_deref(), Some("td") | Some("th")) {
|
|
continue;
|
|
}
|
|
// A hidden cell is removed from the table model entirely (it must
|
|
// not reserve a column slot, or the surviving cells shift right).
|
|
if styles.get(&cid).map(|s| s.display == crate::Display::None).unwrap_or(false) {
|
|
continue;
|
|
}
|
|
while occupied.contains(&(r, c)) {
|
|
c += 1;
|
|
}
|
|
if c >= MAX_COLS {
|
|
break;
|
|
}
|
|
let cs = span_attr(cid, "colspan").clamp(1, MAX_SPAN);
|
|
// rowspan=0 means "span to the end"; a cell can never span more rows
|
|
// than remain, so clamp both to the rows left (which also bounds the
|
|
// occupancy fill and keeps spans within taffy's range).
|
|
let rs_raw = span_attr(cid, "rowspan");
|
|
let rs = if rs_raw == 0 { nrows - r } else { rs_raw }.clamp(1, nrows - r);
|
|
for dr in 0..rs {
|
|
for dc in 0..cs {
|
|
occupied.insert((r + dr, c + dc));
|
|
}
|
|
}
|
|
placed.push((cid, r, c, rs, cs));
|
|
c += cs;
|
|
ncols = ncols.max(c).min(MAX_COLS);
|
|
}
|
|
}
|
|
if placed.is_empty() || ncols == 0 {
|
|
return None;
|
|
}
|
|
|
|
// Build each cell and pin it to its grid area.
|
|
let mut children: Vec<taffy::NodeId> = Vec::new();
|
|
for (cid, r, c, rs, cs) in &placed {
|
|
let Some(cell_node) = build(tree, *cid, taffy_tree, id_map, words, engine, ifc_items, styles) else {
|
|
continue;
|
|
};
|
|
if let Ok(cur) = taffy_tree.style(cell_node) {
|
|
let mut cstyle = cur.clone();
|
|
cstyle.grid_row = taffy::Line { start: line((*r as i16) + 1), end: span(*rs as u16) };
|
|
cstyle.grid_column = taffy::Line { start: line((*c as i16) + 1), end: span(*cs as u16) };
|
|
// Grid does the sizing; a leftover flex_grow from the flex-table
|
|
// heuristic would be ignored anyway, but clear it to be explicit.
|
|
cstyle.flex_grow = 0.0;
|
|
// A cell's specified width sizes its COLUMN (fed into the track
|
|
// by the pre-pass above); the cell box itself always fills its
|
|
// grid area. Left in place, a `width:50%` cell would shrink to
|
|
// half of its own already-halved track.
|
|
cstyle.size.width = Dimension::auto();
|
|
let _ = taffy_tree.set_style(cell_node, cstyle);
|
|
}
|
|
children.push(cell_node);
|
|
}
|
|
if children.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Column sizing pre-pass: specified widths on `<col>` elements and on
|
|
// colspan-1 cells feed the tracks, so author column sizing actually
|
|
// applies (a `td{width:200px}` must size the COLUMN, across every row).
|
|
// A percent width becomes a percent track (resolving against the table),
|
|
// a px width caps the track at that length (min-content still protects
|
|
// the content), and unspecified columns keep content sizing with an
|
|
// `auto` max so they stretch to fill a definite table width instead of
|
|
// leaving a dead strip of bare table background.
|
|
let mut col_px: Vec<Option<f32>> = vec![None; ncols];
|
|
let mut col_pct: Vec<Option<f32>> = vec![None; ncols];
|
|
let attr_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
|
|
let Some(v) = tree.get_node(cid).and_then(|n| n.get_attribute("width").map(|s| s.trim().to_string())) else {
|
|
return (None, None);
|
|
};
|
|
if let Some(p) = v.strip_suffix('%').and_then(|s| s.trim().parse::<f32>().ok()) {
|
|
(None, Some(p / 100.0))
|
|
} else {
|
|
(v.trim_end_matches("px").trim().parse::<f32>().ok(), None)
|
|
}
|
|
};
|
|
let style_width = |cid: NodeId| -> (Option<f32>, Option<f32>) {
|
|
match styles.get(&cid).map(|s| s.width) {
|
|
Some(crate::Dimension::Px(w)) if w > 0.0 => (Some(w), None),
|
|
Some(crate::Dimension::Percent(p)) if p > 0.0 => (None, Some(p)),
|
|
_ => attr_width(cid),
|
|
}
|
|
};
|
|
// <col> elements (direct or under <colgroup>), each spanning `span` columns.
|
|
let mut next_col = 0usize;
|
|
let mut col_elems: Vec<NodeId> = Vec::new();
|
|
for cid in tree.children(id) {
|
|
match tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())).as_deref() {
|
|
Some("col") => col_elems.push(cid),
|
|
Some("colgroup") => {
|
|
for gc in tree.children(cid) {
|
|
if tree.get_node(gc).and_then(|n| n.as_element().map(|e| e.local.as_ref() == "col")).unwrap_or(false) {
|
|
col_elems.push(gc);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
for col_el in col_elems {
|
|
let span = tree
|
|
.get_node(col_el)
|
|
.and_then(|n| n.get_attribute("span").and_then(|v| v.trim().parse::<usize>().ok()))
|
|
.unwrap_or(1)
|
|
.clamp(1, MAX_SPAN);
|
|
let (px, pct) = style_width(col_el);
|
|
for _ in 0..span {
|
|
if next_col >= ncols {
|
|
break;
|
|
}
|
|
col_px[next_col] = px;
|
|
col_pct[next_col] = pct;
|
|
next_col += 1;
|
|
}
|
|
}
|
|
// colspan-1 cells override <col> (they are closer to the content).
|
|
for (cid, _r, c, _rs, cs) in &placed {
|
|
if *cs != 1 || *c >= ncols {
|
|
continue;
|
|
}
|
|
let (mut px, pct) = style_width(*cid);
|
|
// A fixed width declared on a cell describes its content box unless
|
|
// the author opted into border-box. Grid tracks describe the cell's
|
|
// outer border box, so carry padding and border into the pinned track.
|
|
// `<col>` widths above already describe the column track and must not
|
|
// receive a particular cell's box edges.
|
|
if let (Some(w), Some(s)) = (px, styles.get(cid)) {
|
|
if s.box_sizing == crate::BoxSizing::ContentBox {
|
|
px = Some(
|
|
w + s.padding.left
|
|
+ s.padding.right
|
|
+ s.border.left
|
|
+ s.border.right,
|
|
);
|
|
}
|
|
}
|
|
if let Some(w) = px {
|
|
col_px[*c] = Some(col_px[*c].map_or(w, |cur| cur.max(w)));
|
|
}
|
|
if let Some(p) = pct {
|
|
col_pct[*c] = Some(col_pct[*c].map_or(p, |cur| cur.max(p)));
|
|
}
|
|
}
|
|
|
|
// Row sizing: a `height` on the row or a rowspan-1 cell is a MINIMUM
|
|
// (content can always grow a row), matching how tables treat heights.
|
|
let mut row_min: Vec<Option<f32>> = vec![None; nrows];
|
|
for (r, &tr) in rows.iter().enumerate() {
|
|
if let Some(crate::Dimension::Px(h)) = styles.get(&tr).map(|s| s.height) {
|
|
if h > 0.0 {
|
|
row_min[r] = Some(h);
|
|
}
|
|
}
|
|
}
|
|
for (cid, r, _c, rs, _cs) in &placed {
|
|
if *rs != 1 {
|
|
continue;
|
|
}
|
|
if let Some(crate::Dimension::Px(h)) = styles.get(cid).map(|s| s.height) {
|
|
if h > 0.0 {
|
|
row_min[*r] = Some(row_min[*r].map_or(h, |cur| cur.max(h)));
|
|
}
|
|
}
|
|
}
|
|
|
|
let col = |i: usize| {
|
|
let max = if let Some(p) = col_pct[i] {
|
|
taffy::MaxTrackSizingFunction::percent(p)
|
|
} else if let Some(px) = col_px[i] {
|
|
taffy::MaxTrackSizingFunction::length(px)
|
|
} else {
|
|
taffy::MaxTrackSizingFunction::auto()
|
|
};
|
|
taffy::GridTemplateComponent::Single(taffy::MinMax { min: taffy::MinTrackSizingFunction::min_content(), max })
|
|
};
|
|
let row_track = |r: usize| {
|
|
let min = match row_min[r] {
|
|
Some(h) => taffy::MinTrackSizingFunction::length(h),
|
|
None => taffy::MinTrackSizingFunction::auto(),
|
|
};
|
|
taffy::GridTemplateComponent::Single(taffy::MinMax { min, max: taffy::MaxTrackSizingFunction::auto() })
|
|
};
|
|
// In the separate-border model, border-spacing also exists between the
|
|
// table edge and the first/last row and column. Grid `gap` only covers
|
|
// interior tracks, so model the two outer spacing bands as internal
|
|
// layout padding while leaving the computed CSS padding unchanged.
|
|
let (horizontal_spacing, vertical_spacing) = table_spacing(style);
|
|
let mut grid_style = style.clone();
|
|
grid_style.padding.left += horizontal_spacing;
|
|
grid_style.padding.right += horizontal_spacing;
|
|
grid_style.padding.top += vertical_spacing;
|
|
grid_style.padding.bottom += vertical_spacing;
|
|
let mut tstyle = to_taffy_style(&grid_style);
|
|
tstyle.display = Display::Grid;
|
|
// A percentage width resolves against the container, so keep it and let the
|
|
// used-width pass leave it to taffy. Any other width (px or auto) is forced
|
|
// to auto here so that pass can measure content before choosing the width.
|
|
if !matches!(style.width, crate::Dimension::Percent(_)) {
|
|
tstyle.size.width = Dimension::auto();
|
|
}
|
|
tstyle.grid_template_columns = (0..ncols).map(col).collect();
|
|
tstyle.grid_template_rows = (0..nrows).map(row_track).collect();
|
|
tstyle.gap = taffy::Size {
|
|
width: length(horizontal_spacing),
|
|
height: length(vertical_spacing),
|
|
};
|
|
let table_node = taffy_tree.new_with_children(tstyle, &children).ok()?;
|
|
id_map.insert(table_node, id);
|
|
ifc_items.table_rows.insert(table_node, row_min);
|
|
if col_px.iter().any(Option::is_some) || col_pct.iter().any(Option::is_some) {
|
|
ifc_items.table_cols.insert(table_node, (col_px, col_pct));
|
|
}
|
|
Some(table_node)
|
|
}
|
|
|
|
/// Effective size-query containment for this generated box.
|
|
///
|
|
/// CSS size containment has no effect on a non-atomic inline box or an
|
|
/// internal table box. Keeping the check centralized also prevents those
|
|
/// boxes from exposing queryable size axes through `container_snapshot`.
|
|
fn effective_container_type(style: &crate::LayoutStyle) -> crate::ContainerType {
|
|
if style.internal_flex_container
|
|
|| style.is_table_box
|
|
|| (style.display == crate::Display::Inline && !style.is_inline_block)
|
|
{
|
|
crate::ContainerType::Normal
|
|
} else {
|
|
style.container_type
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ContainerAutoInlineSize {
|
|
FillAvailable,
|
|
Intrinsic,
|
|
StretchedGridItem,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ContainerAutoBlockSize {
|
|
Intrinsic,
|
|
StretchedGridItem,
|
|
}
|
|
|
|
/// Classify how an auto inline-size is resolved. Stretched grid items need
|
|
/// their intrinsic contribution contained without replacing the final auto
|
|
/// size that stretch alignment consumes.
|
|
fn container_auto_inline_size(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> ContainerAutoInlineSize {
|
|
if style.is_inline_block || style.float.is_some() {
|
|
return ContainerAutoInlineSize::Intrinsic;
|
|
}
|
|
if matches!(style.position, Some(taffy::Position::Absolute)) {
|
|
// An absolutely positioned auto width is fill-available only when
|
|
// both inline insets are definite.
|
|
return if style.inset[1].is_none() || style.inset[3].is_none() {
|
|
ContainerAutoInlineSize::Intrinsic
|
|
} else {
|
|
ContainerAutoInlineSize::FillAvailable
|
|
};
|
|
}
|
|
|
|
let mut parent = tree.get_node(id).and_then(|node| node.parent);
|
|
let parent_style = loop {
|
|
let Some(parent_id) = parent else {
|
|
return ContainerAutoInlineSize::FillAvailable;
|
|
};
|
|
let Some(parent_style) = styles.get(&parent_id) else {
|
|
return ContainerAutoInlineSize::FillAvailable;
|
|
};
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
break parent_style;
|
|
};
|
|
match parent_style.display {
|
|
crate::Display::Flex => {
|
|
let row = !matches!(
|
|
parent_style.flex_direction,
|
|
Some(
|
|
taffy::FlexDirection::Column
|
|
| taffy::FlexDirection::ColumnReverse
|
|
)
|
|
);
|
|
if row {
|
|
ContainerAutoInlineSize::Intrinsic
|
|
} else if style
|
|
.align_self
|
|
.unwrap_or(
|
|
parent_style
|
|
.align_items
|
|
.unwrap_or(taffy::AlignItems::STRETCH),
|
|
)
|
|
== taffy::AlignSelf::STRETCH
|
|
{
|
|
ContainerAutoInlineSize::FillAvailable
|
|
} else {
|
|
ContainerAutoInlineSize::Intrinsic
|
|
}
|
|
}
|
|
crate::Display::Grid => {
|
|
if style
|
|
.justify_self
|
|
.unwrap_or(
|
|
parent_style
|
|
.justify_items
|
|
.unwrap_or(taffy::JustifyItems::STRETCH),
|
|
)
|
|
== taffy::AlignSelf::STRETCH
|
|
{
|
|
ContainerAutoInlineSize::StretchedGridItem
|
|
} else {
|
|
ContainerAutoInlineSize::Intrinsic
|
|
}
|
|
}
|
|
crate::Display::Inline => ContainerAutoInlineSize::Intrinsic,
|
|
_ => ContainerAutoInlineSize::FillAvailable,
|
|
}
|
|
}
|
|
|
|
/// Classify an auto block-size for size containment. A stretched grid item
|
|
/// must keep its auto size so final alignment can fill a definite grid area;
|
|
/// only its intrinsic track contribution is contained.
|
|
fn container_auto_block_size(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> ContainerAutoBlockSize {
|
|
// Absolutely positioned descendants are not grid items. Preserve the
|
|
// existing contained intrinsic-height behavior until their independent
|
|
// inset-based fill sizing is modeled explicitly.
|
|
if matches!(style.position, Some(taffy::Position::Absolute)) {
|
|
return ContainerAutoBlockSize::Intrinsic;
|
|
}
|
|
|
|
let mut parent = tree.get_node(id).and_then(|node| node.parent);
|
|
let parent_style = loop {
|
|
let Some(parent_id) = parent else {
|
|
return ContainerAutoBlockSize::Intrinsic;
|
|
};
|
|
let Some(parent_style) = styles.get(&parent_id) else {
|
|
return ContainerAutoBlockSize::Intrinsic;
|
|
};
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
break parent_style;
|
|
};
|
|
|
|
if parent_style.display == crate::Display::Grid
|
|
&& style
|
|
.align_self
|
|
.unwrap_or(
|
|
parent_style
|
|
.align_items
|
|
.unwrap_or(taffy::AlignItems::STRETCH),
|
|
)
|
|
== taffy::AlignSelf::STRETCH
|
|
&& !style.margin_auto[0]
|
|
&& !style.margin_auto[2]
|
|
{
|
|
ContainerAutoBlockSize::StretchedGridItem
|
|
} else {
|
|
ContainerAutoBlockSize::Intrinsic
|
|
}
|
|
}
|
|
|
|
/// Apply the used-size part of `container-type`'s implicit size containment.
|
|
///
|
|
/// The default contain-intrinsic-size is zero. Fill-available block boxes keep
|
|
/// their normal auto inline size, which is already descendant-independent.
|
|
/// Shrink-to-fit boxes instead receive a zero intrinsic inline size, and a
|
|
/// `size` container with auto block-size receives a zero intrinsic block size.
|
|
/// Descendants remain in the layout tree and may visibly overflow; size
|
|
/// containment is not paint containment and must not clip them.
|
|
fn apply_container_size_containment(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
taffy_style: &mut taffy::Style,
|
|
) {
|
|
let kind = effective_container_type(style);
|
|
if kind == crate::ContainerType::Normal {
|
|
return;
|
|
}
|
|
|
|
if matches!(style.min_width, crate::Dimension::Auto) {
|
|
taffy_style.min_size.width = taffy::Dimension::length(0.0);
|
|
}
|
|
let ratio_transfers_inline_size =
|
|
style.aspect_ratio.is_some() && !matches!(style.height, crate::Dimension::Auto);
|
|
if matches!(style.width, crate::Dimension::Auto) && !ratio_transfers_inline_size {
|
|
match container_auto_inline_size(tree, id, style, styles) {
|
|
ContainerAutoInlineSize::Intrinsic => {
|
|
taffy_style.size.width = taffy::Dimension::length(0.0);
|
|
}
|
|
ContainerAutoInlineSize::StretchedGridItem => {
|
|
taffy_style.intrinsic_size_containment.width = true;
|
|
}
|
|
ContainerAutoInlineSize::FillAvailable => {}
|
|
}
|
|
}
|
|
|
|
if kind == crate::ContainerType::Size {
|
|
if matches!(style.min_height, crate::Dimension::Auto) {
|
|
taffy_style.min_size.height = taffy::Dimension::length(0.0);
|
|
}
|
|
let ratio_transfers_block_size =
|
|
style.aspect_ratio.is_some() && !matches!(style.width, crate::Dimension::Auto);
|
|
if matches!(style.height, crate::Dimension::Auto) && !ratio_transfers_block_size {
|
|
match container_auto_block_size(tree, id, style, styles) {
|
|
ContainerAutoBlockSize::Intrinsic => {
|
|
taffy_style.size.height = taffy::Dimension::length(0.0);
|
|
}
|
|
ContainerAutoBlockSize::StretchedGridItem => {
|
|
taffy_style.intrinsic_size_containment.height = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct DeferredCyclicInlineSize {
|
|
node: NodeId,
|
|
flex_item: NodeId,
|
|
slot: usize,
|
|
}
|
|
|
|
/// During a flex item's intrinsic-size calculation, percentages in descendant
|
|
/// inline sizes are cyclic: their containing block does not have its used
|
|
/// width yet. Resolving those percentages against the width inherited from a
|
|
/// more distant ancestor turns the temporary value into a permanent
|
|
/// min-content floor. Gecko instead measures the flex item first, then reflows
|
|
/// its descendants under the item's final main size.
|
|
///
|
|
/// Keep the non-percentage part for the intrinsic pass (percentage basis zero),
|
|
/// and remember enough information to resolve the complete expression after
|
|
/// flex sizing has selected the item's used width.
|
|
fn defer_cyclic_flex_inline_sizes(
|
|
tree: &DomTree,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
root_fs: f32,
|
|
vw: f32,
|
|
vh: f32,
|
|
) -> Vec<DeferredCyclicInlineSize> {
|
|
let candidates: Vec<(NodeId, usize, String)> = styles
|
|
.iter()
|
|
.flat_map(|(&id, style)| {
|
|
[0usize, 2, 4].into_iter().filter_map(move |slot| {
|
|
style.size_expressions[slot]
|
|
.as_ref()
|
|
.filter(|expression| expression.contains('%'))
|
|
.cloned()
|
|
.map(|expression| (id, slot, expression))
|
|
})
|
|
})
|
|
.collect();
|
|
let mut deferred = Vec::new();
|
|
|
|
for (id, slot, expression) in candidates {
|
|
// Start at the expression's containing-box chain, not the sized
|
|
// node itself. A percentage-sized direct child of a nested flex
|
|
// container makes that container's contribution cyclic when the
|
|
// container is itself an item in an outer flex row.
|
|
let mut candidate = tree.get_node(id).and_then(|node| node.parent);
|
|
let mut flex_item = None;
|
|
while let Some(item) = candidate {
|
|
let mut parent = tree.get_node(item).and_then(|node| node.parent);
|
|
while let Some(parent_id) = parent {
|
|
let Some(parent_style) = styles.get(&parent_id) else {
|
|
break;
|
|
};
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
let row_flex = parent_style.display == crate::Display::Flex
|
|
&& !parent_style.internal_flex_container
|
|
&& !matches!(
|
|
parent_style.flex_direction,
|
|
Some(
|
|
taffy::FlexDirection::Column
|
|
| taffy::FlexDirection::ColumnReverse
|
|
)
|
|
);
|
|
let item_is_indefinite = styles.get(&item).map_or(false, |style| {
|
|
(!matches!(style.width, crate::Dimension::Px(_))
|
|
|| style.size_expressions[0]
|
|
.as_deref()
|
|
.is_some_and(|expression| expression.contains('%')))
|
|
&& style.float.is_none()
|
|
&& !matches!(
|
|
style.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
});
|
|
if row_flex && item_is_indefinite {
|
|
flex_item = Some(item);
|
|
}
|
|
break;
|
|
}
|
|
if flex_item.is_some() {
|
|
break;
|
|
}
|
|
candidate = tree.get_node(item).and_then(|node| node.parent);
|
|
}
|
|
let Some(flex_item) = flex_item else {
|
|
continue;
|
|
};
|
|
|
|
let em = styles
|
|
.get(&id)
|
|
.and_then(|style| style.font_size)
|
|
.unwrap_or(root_fs);
|
|
let Some(intrinsic) =
|
|
crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, 0.0)
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(style) = styles.get_mut(&id) {
|
|
let intrinsic = crate::Dimension::Px(intrinsic.max(0.0));
|
|
match slot {
|
|
0 => style.width = intrinsic,
|
|
2 => style.min_width = intrinsic,
|
|
// A cyclic percentage max-size behaves as its initial value
|
|
// during intrinsic contribution sizing; treating 50% as a
|
|
// zero maximum would erase real text/content minimums.
|
|
4 => style.max_width = crate::Dimension::Auto,
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
deferred.push(DeferredCyclicInlineSize { node: id, flex_item, slot });
|
|
}
|
|
|
|
deferred
|
|
}
|
|
|
|
fn resolve_deferred_flex_inline_sizes(
|
|
tree: &DomTree,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &HashMap<taffy::NodeId, NodeId>,
|
|
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
|
deferred: &[DeferredCyclicInlineSize],
|
|
root_fs: f32,
|
|
vw: f32,
|
|
vh: f32,
|
|
) -> bool {
|
|
if deferred.is_empty() {
|
|
return false;
|
|
}
|
|
let taffy_by_dom: HashMap<NodeId, taffy::NodeId> =
|
|
id_map.iter().map(|(&taffy, &dom)| (dom, taffy)).collect();
|
|
|
|
// This is the final-reflow boundary: preserve the main size selected by
|
|
// the outer flex algorithm while descendants are resolved against it.
|
|
let flex_items: HashSet<NodeId> = deferred.iter().map(|entry| entry.flex_item).collect();
|
|
for flex_item in flex_items {
|
|
let Some(&taffy_id) = taffy_by_dom.get(&flex_item) else {
|
|
continue;
|
|
};
|
|
let Ok(layout) = taffy_tree.layout(taffy_id) else {
|
|
continue;
|
|
};
|
|
let Some(style) = styles.get(&flex_item) else {
|
|
continue;
|
|
};
|
|
let horizontal_edges =
|
|
style.padding.left + style.padding.right + style.border.left + style.border.right;
|
|
let used_declaration = if style.box_sizing == crate::BoxSizing::ContentBox {
|
|
(layout.size.width - horizontal_edges).max(0.0)
|
|
} else {
|
|
layout.size.width
|
|
};
|
|
if let Ok(current) = taffy_tree.style(taffy_id) {
|
|
let mut fixed = current.clone();
|
|
fixed.size.width = taffy::Dimension::length(used_declaration);
|
|
let _ = taffy_tree.set_style(taffy_id, fixed);
|
|
}
|
|
}
|
|
|
|
for entry in deferred {
|
|
let Some(expression) = styles
|
|
.get(&entry.node)
|
|
.and_then(|style| style.size_expressions[entry.slot].clone())
|
|
else {
|
|
continue;
|
|
};
|
|
let mut containing = tree.get_node(entry.node).and_then(|node| node.parent);
|
|
let basis = loop {
|
|
let Some(parent) = containing else {
|
|
break None;
|
|
};
|
|
let parent_style = styles.get(&parent);
|
|
if parent_style.is_some_and(|style| style.display_contents) {
|
|
containing = tree.get_node(parent).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
let Some(&taffy_id) = taffy_by_dom.get(&parent) else {
|
|
containing = tree.get_node(parent).and_then(|node| node.parent);
|
|
continue;
|
|
};
|
|
let Ok(layout) = taffy_tree.layout(taffy_id) else {
|
|
break None;
|
|
};
|
|
let edges = parent_style.map_or(0.0, |style| {
|
|
style.padding.left + style.padding.right + style.border.left + style.border.right
|
|
});
|
|
break Some((layout.size.width - edges).max(0.0));
|
|
};
|
|
let Some(basis) = basis else {
|
|
continue;
|
|
};
|
|
let em = styles
|
|
.get(&entry.node)
|
|
.and_then(|style| style.font_size)
|
|
.unwrap_or(root_fs);
|
|
let Some(value) =
|
|
crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, basis)
|
|
else {
|
|
continue;
|
|
};
|
|
let value = value.max(0.0);
|
|
if let Some(style) = styles.get_mut(&entry.node) {
|
|
let value = crate::Dimension::Px(value);
|
|
match entry.slot {
|
|
0 => style.width = value,
|
|
2 => style.min_width = value,
|
|
4 => style.max_width = value,
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
let Some(&taffy_id) = taffy_by_dom.get(&entry.node) else {
|
|
continue;
|
|
};
|
|
if let Ok(current) = taffy_tree.style(taffy_id) {
|
|
let mut resolved = current.clone();
|
|
match entry.slot {
|
|
0 => resolved.size.width = taffy::Dimension::length(value),
|
|
2 => resolved.min_size.width = taffy::Dimension::length(value),
|
|
4 => resolved.max_size.width = taffy::Dimension::length(value),
|
|
_ => unreachable!(),
|
|
}
|
|
let _ = taffy_tree.set_style(taffy_id, resolved);
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn build(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Option<taffy::NodeId> {
|
|
let node = tree.get_node(id)?;
|
|
let _name = node.as_element()?;
|
|
let style = styles.get(&id)?;
|
|
if style.display == crate::Display::None {
|
|
return None;
|
|
}
|
|
|
|
// Real table layout: a <table> becomes a CSS grid so columns negotiate
|
|
// across rows (the flex approximation could not) and colspan/rowspan map to
|
|
// grid spans. Falls through to the generic path only if the table has no
|
|
// usable rows/cells.
|
|
if _name.local.as_ref() == "table" {
|
|
if let Some(node) = build_table(tree, id, taffy_tree, id_map, words, engine, ifc_items, styles) {
|
|
return Some(node);
|
|
}
|
|
}
|
|
|
|
let mut taffy_style = to_taffy_style(style);
|
|
apply_container_size_containment(tree, id, style, styles, &mut taffy_style);
|
|
|
|
// A non-stretched flex item in a column flex container uses fit-content
|
|
// for its auto inline size. In a nested flex layout taffy can retain the
|
|
// item's earlier max-content measurement after the outer row shrinks its
|
|
// column, so direct prose stays one enormous line instead of being
|
|
// remeasured at the final column width (MDN's contributor quote).
|
|
//
|
|
// Taffy has no fit-content box-size value. For the narrow text-container
|
|
// case, a synthetic percentage max has the same final effect: it is
|
|
// indefinite during intrinsic measurement, then caps the item to its
|
|
// containing block once that width is known. The automatic min-content
|
|
// size still wins for an unbreakable word, preserving intentional
|
|
// overflow. Keep the workaround away from replaced/atomic boxes and
|
|
// authored sizing constraints.
|
|
if needs_column_flex_text_fit_content_cap(tree, id, style, styles) {
|
|
taffy_style.max_size.width = taffy::Dimension::percent(1.0);
|
|
}
|
|
|
|
// A grid item's automatic minimum size is clamped by a definite
|
|
// max-width. Taffy's intrinsic track pass cannot resolve a percentage max
|
|
// until the track exists, so `max-width:100%` otherwise participates with
|
|
// its full max-content minimum and circularly expands an implicit auto
|
|
// track (a row of fixed gallery columns widened Tailwind's 1200px content
|
|
// track to 1528px). Let the authored percentage remain relative to the
|
|
// final grid area, but remove that circular automatic minimum. Do not
|
|
// eagerly turn the percentage into viewport pixels: a grid area may be
|
|
// narrower than its container because of gutters.
|
|
if matches!(style.max_width, crate::Dimension::Percent(_))
|
|
&& matches!(style.min_width, crate::Dimension::Auto)
|
|
{
|
|
let mut parent = node.parent;
|
|
let grid_item = loop {
|
|
let Some(parent_id) = parent else { break false };
|
|
let Some(parent_style) = styles.get(&parent_id) else {
|
|
break false;
|
|
};
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
break parent_style.display == crate::Display::Grid;
|
|
};
|
|
if grid_item {
|
|
taffy_style.min_size.width = taffy::Dimension::length(0.0);
|
|
}
|
|
}
|
|
|
|
// A forced break that reaches the general box builder sits between block
|
|
// or atomic siblings, so it cannot be folded into a shaped inline run.
|
|
// Give that sentinel the inherited used line-height instead of the UA
|
|
// style's zero placeholder. This is the block/flex analogue of BRFrame's
|
|
// empty-line contribution; break tags inside text and mixed block runs
|
|
// are handled by the inline collector below.
|
|
if _name.local.as_ref() == "br" {
|
|
let height = crate::inline::used_line_height(style).max(0.0);
|
|
taffy_style.size.height = taffy::style::Dimension::length(height);
|
|
let leaf = taffy_tree.new_leaf(taffy_style).ok()?;
|
|
id_map.insert(leaf, id);
|
|
return Some(leaf);
|
|
}
|
|
|
|
// CSS has separate outer and inner display types, while taffy exposes one
|
|
// display value. An inline-block normally needs our wrapping inline
|
|
// approximation so it remains atomic in an inline formatting context.
|
|
// As a direct flex/grid item, however, its outer display is blockified and
|
|
// its flow-root inner display must stack block children. Apply the block
|
|
// inner mode only in that formatting context; doing it globally turns
|
|
// inline-block lists into one full-width item per line.
|
|
if style.is_inline_block {
|
|
let mut parent = node.parent;
|
|
let flex_or_grid_item = loop {
|
|
let Some(parent_id) = parent else { break false };
|
|
let Some(parent_style) = styles.get(&parent_id) else { break false };
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
break matches!(
|
|
parent_style.display,
|
|
crate::Display::Flex | crate::Display::Grid
|
|
) && !parent_style.internal_flex_container;
|
|
};
|
|
if flex_or_grid_item {
|
|
taffy_style.display = taffy::style::Display::Block;
|
|
taffy_style.flex_wrap = taffy::FlexWrap::NoWrap;
|
|
} else if style.width == crate::Dimension::Auto {
|
|
// An auto-width inline-block shrink-fits to its max-content width
|
|
// when that fits the available line. Taffy's wrapping flex
|
|
// approximation otherwise chooses a min-content width first,
|
|
// turning ordinary two-word buttons into two-line controls. Keep
|
|
// wrapping for explicitly constrained inline-blocks and for ones
|
|
// with real in-flow block children; those need their inner block
|
|
// formatting rather than a single max-content line.
|
|
let has_in_flow_block_child = tree.children(id).iter().any(|child| {
|
|
styles.get(child).map_or(false, |child_style| {
|
|
child_style.display == crate::Display::Block
|
|
&& !matches!(
|
|
child_style.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
})
|
|
});
|
|
if !has_in_flow_block_child {
|
|
taffy_style.flex_wrap = taffy::FlexWrap::NoWrap;
|
|
}
|
|
}
|
|
}
|
|
|
|
// An inline SVG is an atomic replaced box in the surrounding formatting
|
|
// context. Its descendants paint inside its SVG viewport; they must not
|
|
// become CSS layout children. In particular, a viewBox supplies an
|
|
// intrinsic ratio whose auto axis is transferred from a percentage-sized
|
|
// definite axis during the grid's final pass. Treating the SVG as a normal
|
|
// empty container makes its intrinsic contribution zero during grid track
|
|
// sizing, collapsing responsive logo walls to zero-height rows.
|
|
if _name.local.as_ref() == "svg" {
|
|
if let Some(ratio) = style.aspect_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
|
{
|
|
// A percentage inline size inside an auto grid track is cyclic
|
|
// during intrinsic sizing: it behaves as auto for the contribution
|
|
// pass, then fills the final track. Taffy 0.12 keeps the intrinsic
|
|
// fallback width instead of revisiting that percentage. Express
|
|
// the final fill as grid self-stretch so the known content width
|
|
// reaches the replaced-item measure callback.
|
|
if matches!(style.width, crate::Dimension::Percent(_))
|
|
&& matches!(style.height, crate::Dimension::Auto)
|
|
{
|
|
taffy_style.size.width = taffy::Dimension::auto();
|
|
taffy_style.min_size.width = taffy::Dimension::length(0.0);
|
|
taffy_style.justify_self = Some(taffy::AlignSelf::STRETCH);
|
|
}
|
|
// A viewBox supplies a ratio but no intrinsic dimensions. The
|
|
// 300px default inline size is CSS Images' default object size;
|
|
// the ratio supplies the corresponding block size. Definite CSS
|
|
// axes still arrive through `known` and override this fallback.
|
|
let width = 300.0;
|
|
let height = width / ratio;
|
|
let context = engine.register_replaced(width, height, style);
|
|
let leaf = taffy_tree
|
|
.new_leaf_with_context(taffy_style, context)
|
|
.ok()?;
|
|
id_map.insert(leaf, id);
|
|
return Some(leaf);
|
|
}
|
|
}
|
|
|
|
// A replaced image is a measured leaf, even when CSS gives it a percentage
|
|
// width. Its intrinsic dimensions participate in an auto-sized ancestor's
|
|
// max-content measurement; once the percentage axis becomes definite, the
|
|
// measure callback derives the other axis through the intrinsic ratio.
|
|
if _name.local.as_ref() == "img" {
|
|
if let Some((width, height)) = style.intrinsic_size {
|
|
let intrinsic_ratio =
|
|
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
|
|
width / height
|
|
} else {
|
|
1.0
|
|
};
|
|
let preferred_ratio = style
|
|
.aspect_ratio
|
|
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
|
.unwrap_or(intrinsic_ratio);
|
|
|
|
// Taffy has one preferred-size field but CSS intrinsic sizing has
|
|
// separate preferred and min-content contributions. For a proper
|
|
// replaced element with a percentage maximum, encode the
|
|
// equivalent min(preferred-width, percentage-max) function by
|
|
// swapping the two operands: the percentage is indefinite during
|
|
// intrinsic contribution sizing and resolves in final layout,
|
|
// while the fixed maximum retains the replaced box's preferred
|
|
// width. This applies to authored sizes and intrinsic auto sizes
|
|
// alike; it is not tied to HTML width attributes.
|
|
if let crate::Dimension::Percent(maximum) = style.max_width {
|
|
let preferred_width = match style.width {
|
|
crate::Dimension::Px(width) => Some(width),
|
|
crate::Dimension::Auto => match style.height {
|
|
crate::Dimension::Px(height) => Some(height * preferred_ratio),
|
|
_ => Some(
|
|
crate::inline::constrained_auto_replaced_size(
|
|
width, height, style,
|
|
)
|
|
.width,
|
|
),
|
|
},
|
|
_ => None,
|
|
};
|
|
if let Some(preferred_width) = preferred_width {
|
|
taffy_style.size.width = taffy::Dimension::percent(maximum);
|
|
taffy_style.max_size.width =
|
|
taffy::Dimension::length(preferred_width.max(0.0));
|
|
}
|
|
}
|
|
|
|
// When both preferred axes are auto and all min/max constraints
|
|
// are definite, resolve CSS2's ratio-preserving constraint table
|
|
// before handing the leaf to taffy. Otherwise taffy's block
|
|
// stretch supplies a containing-block width as a known dimension
|
|
// and independently clamps the height, distorting the ratio.
|
|
let has_definite_constraint = [
|
|
style.min_width,
|
|
style.min_height,
|
|
style.max_width,
|
|
style.max_height,
|
|
]
|
|
.into_iter()
|
|
.any(|dimension| matches!(dimension, crate::Dimension::Px(_)));
|
|
let has_percentage_constraint = [
|
|
style.min_width,
|
|
style.min_height,
|
|
style.max_width,
|
|
style.max_height,
|
|
]
|
|
.into_iter()
|
|
.any(|dimension| matches!(dimension, crate::Dimension::Percent(_)))
|
|
|| style.size_expressions[2..=5]
|
|
.iter()
|
|
.flatten()
|
|
.any(|expression| expression.contains('%'));
|
|
if matches!(style.width, crate::Dimension::Auto)
|
|
&& matches!(style.height, crate::Dimension::Auto)
|
|
&& has_definite_constraint
|
|
&& !has_percentage_constraint
|
|
{
|
|
let constrained =
|
|
crate::inline::constrained_auto_replaced_size(width, height, style);
|
|
taffy_style.size.width = taffy::Dimension::length(constrained.width);
|
|
taffy_style.size.height = taffy::Dimension::length(constrained.height);
|
|
}
|
|
|
|
// When an auto axis has a definite min/max constraint, the
|
|
// measured replaced leaf must own intrinsic-ratio transfer so it
|
|
// can clamp the derived size. Leaving the same ratio on taffy's
|
|
// style lets taffy synthesize that axis before measurement and
|
|
// bypass the constraint (width:100%; height:auto;
|
|
// max-height:200px became the uncapped intrinsic height).
|
|
//
|
|
// Keep taffy's ratio in the unconstrained case: percentage-sized
|
|
// images in auto wrappers can be measured at intrinsic size before
|
|
// their percentage width becomes definite, and taffy then needs
|
|
// the ratio to transfer that final width to height.
|
|
let measured_axis_constraint =
|
|
(!matches!(style.height, crate::Dimension::Auto)
|
|
&& matches!(style.width, crate::Dimension::Auto)
|
|
&& matches!(
|
|
(style.min_width, style.max_width),
|
|
(crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_))
|
|
))
|
|
|| (!matches!(style.width, crate::Dimension::Auto)
|
|
&& matches!(style.height, crate::Dimension::Auto)
|
|
&& matches!(
|
|
(style.min_height, style.max_height),
|
|
(crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_))
|
|
));
|
|
if measured_axis_constraint {
|
|
taffy_style.aspect_ratio = None;
|
|
}
|
|
let context = engine.register_replaced(width, height, style);
|
|
let leaf = taffy_tree.new_leaf_with_context(taffy_style, context).ok()?;
|
|
id_map.insert(leaf, id);
|
|
return Some(leaf);
|
|
}
|
|
}
|
|
|
|
// If this container is a pure-text inline formatting context, collapse its
|
|
// whole subtree to one leaf shaped and line-broken by cosmic-text (real
|
|
// text layout), sized on demand by the measure function. This is the fast,
|
|
// correct path for paragraphs/headings/labels/cells of text; the flex-wrap
|
|
// approximation below only handles the leftovers (mixed inline + atomic
|
|
// boxes, and layout-only builds where `try_build` always declines).
|
|
if let Some(item) = engine.try_build(tree, id, styles) {
|
|
if style.display == crate::Display::Block && style.width == crate::Dimension::Auto {
|
|
// A pure-text block is still a fill-available block. Its shaped
|
|
// inline context performs text alignment internally; retaining
|
|
// the flex alignment stand-in here shrink-wraps the leaf to its
|
|
// text, leaving no free width in which center/end can move it.
|
|
taffy_style.display = taffy::style::Display::Block;
|
|
}
|
|
let leaf = taffy_tree.new_leaf_with_context(taffy_style, item).ok()?;
|
|
id_map.insert(leaf, id);
|
|
ifc_items.whole.insert(id, item);
|
|
return Some(leaf);
|
|
}
|
|
|
|
// Taffy has no real inline formatting context: its native Block layout
|
|
// treats every direct child as its own full-width block box, stacked
|
|
// vertically. That is correct when a block's children are other block
|
|
// elements (a div full of divs), but wrong the moment a block holds
|
|
// inline-level content (running text with <a>/<span>/<b> mixed in, i.e.
|
|
// any ordinary paragraph or list item), where real CSS instead lays
|
|
// consecutive inline boxes out left-to-right, wrapping at the container's
|
|
// width. Approximate that inline formatting context by promoting such
|
|
// blocks to a wrapping flex row, so text and inline elements actually
|
|
// flow together on shared lines instead of each getting its own row.
|
|
//
|
|
// The same fix applies to `<td>`/`<th>` (and any other UA default that
|
|
// stacks its children in a flex column rather than taffy's Block mode):
|
|
// without it, a text node's per-word leaves (see `build_text_words`)
|
|
// become direct children of a column-direction flex container and each
|
|
// word lands on its own line, one word per row, instead of wrapping
|
|
// several words per line the way inline text actually flows.
|
|
let stacks_children_vertically = style.display == crate::Display::Block
|
|
|| (style.display == crate::Display::Flex
|
|
&& style.flex_direction == Some(taffy::FlexDirection::Column));
|
|
let has_inline_ish_content = has_inline_content(tree, id, styles)
|
|
|| has_in_flow_generated_pseudo(style.before_pseudo.as_deref())
|
|
|| has_in_flow_generated_pseudo(style.after_pseudo.as_deref());
|
|
|
|
let mut dom_children = tree.children(id);
|
|
// A boxless inline wrapper is transparent to our approximated box tree.
|
|
// Flatten it before deciding whether this block has mixed inline/block
|
|
// content, not only later while building children. Otherwise
|
|
// `<pre><code><span style="display:block">…` looks like one inline child
|
|
// during classification, is promoted to a flex row, and only then exposes
|
|
// the block spans—too late, so every line lands side by side. Real engines
|
|
// split the inline around those blocks and make the blocks siblings in the
|
|
// containing block formatting context.
|
|
if style.display == crate::Display::Block || style.internal_flex_container {
|
|
let mut flattened = Vec::new();
|
|
flatten_boxless_inline_children(tree, &dom_children, styles, &mut flattened);
|
|
dom_children = flattened;
|
|
}
|
|
let internal_mixed_block_flow = style.internal_flex_container
|
|
&& dom_children.iter().any(|cid| {
|
|
styles.get(cid).map_or(false, |child| {
|
|
child.display == crate::Display::Block
|
|
&& child.float.is_none()
|
|
&& !matches!(
|
|
child.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
})
|
|
});
|
|
// In flex and grid formatting contexts, collapsible whitespace-only text
|
|
// between items does not generate an anonymous item. Taffy places each
|
|
// stray whitespace leaf in the item sequence, shifting every real child.
|
|
// This is especially visible in pretty-printed HTML: indentation before a
|
|
// flex child becomes several pixels of unexplained leading space.
|
|
//
|
|
// Flatten display:contents first because its children participate as direct
|
|
// flex/grid items. Otherwise formatting whitespace inside the transparent
|
|
// wrapper would survive this filter and become an item in `build_any`.
|
|
if matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|
|
&& !internal_mixed_block_flow
|
|
{
|
|
let mut flat = Vec::new();
|
|
flatten_contents_children(tree, &dom_children, styles, &mut flat);
|
|
dom_children = flat;
|
|
dom_children.retain(|&cid| {
|
|
tree.get_node(cid).map_or(false, |n| n.is_element()) || !tree.text_content(cid).trim().is_empty()
|
|
});
|
|
// Flex and grid placement consume the order-modified document order.
|
|
// Rust's stable sort preserves source order for equal values, exactly
|
|
// the CSS tie-break. Taffy has no CSS `order` style field, so feeding
|
|
// it the correctly ordered item sequence is the missing translation.
|
|
dom_children.sort_by_key(|cid| styles.get(cid).map(|style| style.order).unwrap_or(0));
|
|
} else if style.display == crate::Display::Block {
|
|
// Formatting whitespace between block lines does not generate an
|
|
// inline line box. Keeping a zero-height taffy leaf here still breaks
|
|
// sibling margin adjacency, turning `margin-bottom:30px` followed by
|
|
// `margin-top:40px` into 70px instead of the collapsed 40px. Preserve
|
|
// whitespace only when this parent actually has inline content, where
|
|
// the inter-element space belongs to the inline formatting context.
|
|
if !has_inline_ish_content {
|
|
dom_children.retain(|&cid| {
|
|
tree.get_node(cid).map_or(false, |node| node.is_element())
|
|
|| !tree.text_content(cid).trim().is_empty()
|
|
});
|
|
}
|
|
// A float nested directly in a transparent inline wrapper (the common
|
|
// `<a><img style="float:left"></a>` logo pattern) belongs to the
|
|
// ancestor block formatting context. Gecko reparents that out-of-flow
|
|
// frame to the BFC and leaves only a placeholder in the inline. Expose
|
|
// the same item to our float-band builder instead of charging the
|
|
// wrapper a full normal-flow row.
|
|
for child in &mut dom_children {
|
|
if let Some(float) = inline_wrapper_float(tree, *child, styles) {
|
|
*child = float;
|
|
}
|
|
}
|
|
}
|
|
// Main-axis auto margins absorb positive free space before
|
|
// `justify-content` is applied. Taffy 0.12 currently applies both: it
|
|
// end-justifies the items and then adds the auto-margin space again,
|
|
// placing later controls far outside the container (MDN's theme/language
|
|
// controls landed beyond x=2100). Neutralize justify-content whenever an
|
|
// in-flow flex item owns a main-axis auto margin; taffy then performs the
|
|
// auto-margin distribution correctly.
|
|
if style.display == crate::Display::Flex {
|
|
let column = matches!(
|
|
style.flex_direction,
|
|
Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse)
|
|
);
|
|
let has_main_auto_margin = dom_children.iter().any(|cid| {
|
|
styles.get(cid).map_or(false, |child| {
|
|
if column {
|
|
child.margin_auto[0] || child.margin_auto[2]
|
|
} else {
|
|
child.margin_auto[1] || child.margin_auto[3]
|
|
}
|
|
})
|
|
});
|
|
if has_main_auto_margin {
|
|
taffy_style.justify_content = Some(taffy::JustifyContent::FLEX_START);
|
|
}
|
|
}
|
|
// `float` has no effect on a flex or grid item. Legacy stylesheets often
|
|
// leave floats on children after a newer rule turns their parent into a
|
|
// flex container; routing those children through the block float-zone
|
|
// approximation corrupts flex sizing and percentage-margin placement.
|
|
let has_float_child = style.display == crate::Display::Block
|
|
&& dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false));
|
|
let native_float_band =
|
|
has_float_child && can_use_native_float_band(tree, style, &dom_children, styles);
|
|
let has_in_flow_block_child = dom_children.iter().any(|cid| {
|
|
styles.get(cid).map_or(false, |child| {
|
|
child.display == crate::Display::Block
|
|
&& child.float.is_none()
|
|
&& !matches!(child.position, Some(taffy::Position::Absolute))
|
|
})
|
|
});
|
|
let is_native_table_cell = node.as_element().map_or(false, |element| {
|
|
matches!(element.local.as_ref(), "td" | "th")
|
|
&& style.internal_flex_container
|
|
});
|
|
|
|
// `text-align` affects inline content, never the used width or placement
|
|
// of an in-flow block child. `to_taffy_style` promotes a centered/right
|
|
// block to a flex column as an inline-alignment stand-in; retaining that
|
|
// promotion when the container has real block children makes every auto
|
|
// width child shrink to max-content. That turns full-width paragraphs,
|
|
// responsive picture wrappers, and rows of inline-block buttons into
|
|
// narrow columns that wrap even though their containing block has room.
|
|
//
|
|
// Keep the legacy `<center>` behavior: unlike CSS text-align, that element
|
|
// historically centers block descendants as well.
|
|
if style.display == crate::Display::Block
|
|
&& has_in_flow_block_child
|
|
&& !style.legacy_center
|
|
{
|
|
taffy_style.display = taffy::style::Display::Block;
|
|
}
|
|
|
|
// A block with mixed inline + block children keeps real block layout:
|
|
// block-level children become direct block children (full available
|
|
// width, exactly what CSS block layout gives them), while each maximal
|
|
// run of consecutive inline-level siblings is wrapped in one anonymous
|
|
// block-level box that carries the inline formatting context. This is
|
|
// how real engines structure mixed content, and it replaces the old
|
|
// whole-container flex-row-wrap promotion, which collapsed contentless
|
|
// block children (e.g. a `position:relative` hero wrapper whose only
|
|
// child is out-of-flow) to width 0 and let text and blocks share lines.
|
|
// Floats still take the legacy zone path below.
|
|
if (style.display == crate::Display::Block
|
|
|| (is_native_table_cell && has_in_flow_block_child))
|
|
&& has_inline_ish_content
|
|
&& !has_float_child
|
|
{
|
|
return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles);
|
|
}
|
|
|
|
if stacks_children_vertically
|
|
&& has_inline_ish_content
|
|
&& !native_float_band
|
|
&& !(has_float_child && has_in_flow_block_child)
|
|
{
|
|
taffy_style.display = taffy::style::Display::Flex;
|
|
taffy_style.flex_direction = taffy::FlexDirection::Row;
|
|
taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
|
|
|
|
// The promoted container's main axis is horizontal, so text alignment
|
|
// belongs on `justify_content`. Real `justify-content` from actual CSS
|
|
// wins if present.
|
|
if style.justify_content.is_none() {
|
|
taffy_style.justify_content = match style.text_align {
|
|
Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END),
|
|
Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER),
|
|
_ => taffy_style.justify_content,
|
|
};
|
|
}
|
|
taffy_style.align_items = Some(taffy::AlignItems::FLEX_START);
|
|
}
|
|
|
|
if native_float_band {
|
|
// Native float placement is only selected for the guarded flat-band
|
|
// shape. It requires a real block formatting context on the parent.
|
|
taffy_style.display = taffy::style::Display::Block;
|
|
}
|
|
|
|
let mut child_ids: Vec<taffy::NodeId> = if native_float_band {
|
|
build_children_with_native_float_band(
|
|
tree,
|
|
id,
|
|
style,
|
|
&dom_children,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
)
|
|
} else if has_float_child {
|
|
build_children_with_float_zone(tree, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles)
|
|
} else if matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|
|
&& !style.internal_flex_container
|
|
{
|
|
build_flex_grid_children(
|
|
tree,
|
|
id,
|
|
&dom_children,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
)
|
|
} else {
|
|
dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect()
|
|
};
|
|
if !native_float_band {
|
|
if let Some((mut before, _)) = build_in_flow_pseudo(
|
|
id,
|
|
GeneratedBoxKind::Before,
|
|
style.before_pseudo.as_deref(),
|
|
taffy_tree,
|
|
words,
|
|
ifc_items,
|
|
) {
|
|
before.append(&mut child_ids);
|
|
child_ids = before;
|
|
}
|
|
if let Some((mut after, _)) = build_in_flow_pseudo(
|
|
id,
|
|
GeneratedBoxKind::After,
|
|
style.after_pseudo.as_deref(),
|
|
taffy_tree,
|
|
words,
|
|
ifc_items,
|
|
) {
|
|
child_ids.append(&mut after);
|
|
}
|
|
}
|
|
|
|
if style.internal_flex_container
|
|
&& taffy_style.display == taffy::style::Display::Flex
|
|
&& taffy_style.flex_direction == taffy::FlexDirection::Column
|
|
{
|
|
// Table cells and the other native block-layout stand-ins are not
|
|
// genuine CSS flex containers. Their block children neither flex-shrink
|
|
// to an authored container height nor shrink-wrap along the inline
|
|
// axis. Preserving the children's block sizes is particularly
|
|
// important for table cells, where `height` is only a row minimum and
|
|
// taller content must grow the row.
|
|
for child in &child_ids {
|
|
let fills_inline_axis = id_map
|
|
.get(child)
|
|
.and_then(|dom_id| styles.get(dom_id))
|
|
.map_or(false, |child_style| {
|
|
child_style.display == crate::Display::Block
|
|
&& child_style.width == crate::Dimension::Auto
|
|
&& child_style.float.is_none()
|
|
&& !matches!(
|
|
child_style.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
});
|
|
if let Ok(current) = taffy_tree.style(*child) {
|
|
let mut block_item = current.clone();
|
|
block_item.flex_shrink = 0.0;
|
|
if fills_inline_axis {
|
|
block_item.size.width = taffy::style::Dimension::percent(1.0);
|
|
}
|
|
let _ = taffy_tree.set_style(*child, block_item);
|
|
}
|
|
}
|
|
}
|
|
|
|
let multicol_count = style
|
|
.column_count
|
|
.filter(|count| *count > 1)
|
|
.map(usize::from);
|
|
// Box-level balancing is sound only when every generated direct box is an
|
|
// authored atomic fragment. Ordinary prose needs line-level fragmentation;
|
|
// pretending its paragraph boxes are columns would be worse than the
|
|
// current single-column fallback. This gate intentionally makes
|
|
// `break-inside:avoid` the capability boundary until inline fragment
|
|
// continuations exist.
|
|
let atomic_multicol_children = !child_ids.is_empty()
|
|
&& child_ids.iter().all(|child| {
|
|
id_map
|
|
.get(child)
|
|
.and_then(|dom_id| styles.get(dom_id))
|
|
.map_or(false, |child_style| {
|
|
child_style.break_inside_avoid
|
|
&& child_style.float.is_none()
|
|
&& !matches!(
|
|
child_style.position,
|
|
Some(taffy::Position::Absolute)
|
|
)
|
|
})
|
|
});
|
|
let taffy_id = if let Some(column_count) =
|
|
multicol_count.filter(|_| atomic_multicol_children)
|
|
{
|
|
// A multicol container keeps its ordinary outer/block box, but its
|
|
// inner formatting context is a horizontal sequence of equal-width
|
|
// fragmentainers. Taffy does not expose CSS fragmentation, so build
|
|
// those fragmentainers explicitly. The first preliminary layout puts
|
|
// every child in column one, which measures each subtree at the exact
|
|
// final column width. `apply_multicol_balance` then performs the
|
|
// bounded Gecko-style feasible-height search and reparents the
|
|
// already measured boxes before the final layout.
|
|
taffy_style.display = taffy::style::Display::Flex;
|
|
taffy_style.flex_direction = taffy::FlexDirection::Row;
|
|
taffy_style.flex_wrap = taffy::FlexWrap::NoWrap;
|
|
if style.column_gap.is_none() {
|
|
taffy_style.gap.width =
|
|
taffy::style::LengthPercentage::length(style.font_size.unwrap_or(16.0));
|
|
}
|
|
let column_style = taffy::Style {
|
|
display: taffy::style::Display::Block,
|
|
flex_grow: 1.0,
|
|
flex_shrink: 1.0,
|
|
flex_basis: taffy::style::Dimension::length(0.0),
|
|
min_size: taffy::Size {
|
|
width: taffy::style::Dimension::length(0.0),
|
|
height: taffy::style::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
let mut columns = Vec::with_capacity(column_count);
|
|
columns.push(
|
|
taffy_tree
|
|
.new_with_children(column_style.clone(), &child_ids)
|
|
.ok()?,
|
|
);
|
|
for _ in 1..column_count {
|
|
columns.push(taffy_tree.new_leaf(column_style.clone()).ok()?);
|
|
}
|
|
let multicol =
|
|
taffy_tree.new_with_children(taffy_style, &columns).ok()?;
|
|
ifc_items.multicol.push(MulticolBuild {
|
|
columns,
|
|
children: child_ids,
|
|
});
|
|
multicol
|
|
} else if child_ids.is_empty() {
|
|
taffy_tree.new_leaf(taffy_style).ok()?
|
|
} else {
|
|
taffy_tree.new_with_children(taffy_style, &child_ids).ok()?
|
|
};
|
|
id_map.insert(taffy_id, id);
|
|
Some(taffy_id)
|
|
}
|
|
|
|
fn inline_wrapper_float(
|
|
tree: &DomTree,
|
|
wrapper: NodeId,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Option<NodeId> {
|
|
let wrapper_style = styles.get(&wrapper)?;
|
|
if wrapper_style.display != crate::Display::Inline
|
|
|| wrapper_style.margin != crate::Edges::default()
|
|
|| wrapper_style.padding != crate::Edges::default()
|
|
|| wrapper_style
|
|
.padding_percent
|
|
.iter()
|
|
.any(|value| value.is_some())
|
|
|| wrapper_style.border != crate::Edges::default()
|
|
|| wrapper_style.before_pseudo.is_some()
|
|
|| wrapper_style.after_pseudo.is_some()
|
|
{
|
|
return None;
|
|
}
|
|
let visible: Vec<NodeId> = tree
|
|
.children(wrapper)
|
|
.into_iter()
|
|
.filter(|&child| {
|
|
let Some(node) = tree.get_node(child) else { return false };
|
|
if !node.is_element() {
|
|
return !tree.text_content(child).trim().is_empty();
|
|
}
|
|
styles
|
|
.get(&child)
|
|
.map(|style| style.display != crate::Display::None)
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
let [child] = visible.as_slice() else { return None };
|
|
styles.get(child).and_then(|style| style.float).map(|_| *child)
|
|
}
|
|
|
|
/// Build a block container whose children mix inline-level and block-level
|
|
/// content, preserving real block layout for the block children.
|
|
///
|
|
/// Structure produced (mirroring how CSS block containers actually work):
|
|
/// - block-level in-flow children stay direct children of the (taffy Block)
|
|
/// parent, so an auto width fills the containing block even when the child
|
|
/// has no in-flow content of its own;
|
|
/// - each maximal run of consecutive inline-level siblings becomes ONE
|
|
/// anonymous block-level box carrying the inline formatting context. A run
|
|
/// of pure text and foldable inline wrappers collapses to a single shaped
|
|
/// cosmic-text leaf (one taffy node for the whole run instead of one per
|
|
/// word: faster and lighter than the old whole-container promotion). Runs
|
|
/// holding atomic inline boxes (img, inline-block, ...) fall back to an
|
|
/// anonymous flex-wrap wrapper around the run's boxes;
|
|
/// - out-of-flow (absolutely positioned) children neither join nor break a
|
|
/// run; they are appended after the flow children so their containing
|
|
/// block is this parent, whose used width they resolve percentages against.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_mixed_block(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
mut taffy_style: taffy::Style,
|
|
dom_children: &[NodeId],
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Option<taffy::NodeId> {
|
|
// The parent is a genuine block container. `to_taffy_style` may have
|
|
// promoted it to a flex column for `text-align` (taffy block layout has
|
|
// no align-items); undo that: text-align moves line content, never
|
|
// block children, so it is applied to the runs below instead.
|
|
taffy_style.display = taffy::style::Display::Block;
|
|
|
|
// Splice display:contents children and drop nothing else, so runs
|
|
// partition over the child list exactly as CSS sees it.
|
|
let mut flat: Vec<NodeId> = Vec::new();
|
|
flatten_contents_children(tree, dom_children, styles, &mut flat);
|
|
|
|
enum Seg {
|
|
Run(Vec<NodeId>),
|
|
Block(NodeId),
|
|
}
|
|
let mut segs: Vec<Seg> = Vec::new();
|
|
let mut out_of_flow: Vec<NodeId> = Vec::new();
|
|
for &cid in &flat {
|
|
let Some(node) = tree.get_node(cid) else { continue };
|
|
let is_text = matches!(node.data, obscura_dom::tree::NodeData::Text { .. });
|
|
// Comments, doctypes, and other non-rendered DOM nodes generate no CSS
|
|
// box and cannot interrupt an inline formatting context. Hydrating
|
|
// frameworks place marker comments between adjacent inline controls;
|
|
// treating each marker as a block segment split one button row into
|
|
// multiple anonymous block wrappers.
|
|
if !is_text && !node.is_element() {
|
|
continue;
|
|
}
|
|
let inline_level = if is_text {
|
|
true
|
|
} else if let Some(s) = styles.get(&cid) {
|
|
if s.display == crate::Display::None {
|
|
continue;
|
|
}
|
|
if matches!(s.position, Some(taffy::Position::Absolute)) {
|
|
out_of_flow.push(cid);
|
|
continue;
|
|
}
|
|
let is_forced_break = node
|
|
.as_element()
|
|
.map_or(false, |element| element.local.as_ref() == "br");
|
|
is_forced_break || crate::is_inline_level_box(s)
|
|
} else {
|
|
false
|
|
};
|
|
if inline_level {
|
|
match segs.last_mut() {
|
|
Some(Seg::Run(run)) => run.push(cid),
|
|
_ => segs.push(Seg::Run(vec![cid])),
|
|
}
|
|
} else {
|
|
segs.push(Seg::Block(cid));
|
|
}
|
|
}
|
|
|
|
// Inline pseudos join the first/last inline run. A block-level pseudo is
|
|
// a real direct child of this block and therefore sits outside the
|
|
// anonymous run wrappers, exactly like an authored block child.
|
|
let before = build_in_flow_pseudo(
|
|
id,
|
|
GeneratedBoxKind::Before,
|
|
style.before_pseudo.as_deref(),
|
|
taffy_tree,
|
|
words,
|
|
ifc_items,
|
|
);
|
|
let after = build_in_flow_pseudo(
|
|
id,
|
|
GeneratedBoxKind::After,
|
|
style.after_pseudo.as_deref(),
|
|
taffy_tree,
|
|
words,
|
|
ifc_items,
|
|
);
|
|
let (before_leaves, before_block) = before.unwrap_or_else(|| (Vec::new(), false));
|
|
let (after_leaves, after_block) = after.unwrap_or_else(|| (Vec::new(), false));
|
|
let mut before_pending = !before_block && !before_leaves.is_empty();
|
|
let mut after_pending = !after_block && !after_leaves.is_empty();
|
|
|
|
let n_segs = segs.len();
|
|
let mut child_ids: Vec<taffy::NodeId> = Vec::new();
|
|
if before_block {
|
|
child_ids.extend(before_leaves.iter().copied());
|
|
}
|
|
for (i, seg) in segs.into_iter().enumerate() {
|
|
match seg {
|
|
Seg::Block(cid) => {
|
|
let built =
|
|
build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles);
|
|
if style.legacy_center {
|
|
let has_default_horizontal_margins = styles.get(&cid).map_or(false, |child| {
|
|
child.margin.left == 0.0
|
|
&& child.margin.right == 0.0
|
|
&& !child.margin_auto[1]
|
|
&& !child.margin_auto[3]
|
|
});
|
|
if has_default_horizontal_margins {
|
|
for child in &built {
|
|
if let Ok(current) = taffy_tree.style(*child) {
|
|
let mut centered = current.clone();
|
|
centered.margin.left =
|
|
taffy::style::LengthPercentageAuto::auto();
|
|
centered.margin.right =
|
|
taffy::style::LengthPercentageAuto::auto();
|
|
let _ = taffy_tree.set_style(*child, centered);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
child_ids.extend(built);
|
|
}
|
|
Seg::Run(run) => {
|
|
let has_text_strut = run.iter().any(|&cid| {
|
|
tree.get_node(cid).map_or(false, |node| {
|
|
matches!(
|
|
node.data,
|
|
obscura_dom::tree::NodeData::Text { .. }
|
|
)
|
|
})
|
|
});
|
|
// Collapsible source formatting at the start/end of an inline
|
|
// run does not create line width. Preserve whitespace between
|
|
// inline siblings, but trim indentation adjacent to block
|
|
// boundaries so pretty-printed markup starts at the line edge.
|
|
let is_whitespace_text = |cid: NodeId| {
|
|
tree.get_node(cid).map_or(false, |node| {
|
|
matches!(node.data, obscura_dom::tree::NodeData::Text { .. })
|
|
&& tree.text_content(cid).trim().is_empty()
|
|
})
|
|
};
|
|
let start = run.iter().position(|&cid| !is_whitespace_text(cid)).unwrap_or(run.len());
|
|
let end = run
|
|
.iter()
|
|
.rposition(|&cid| !is_whitespace_text(cid))
|
|
.map(|index| index + 1)
|
|
.unwrap_or(start);
|
|
let run = &run[start..end];
|
|
let join_before = before_pending && i == 0;
|
|
let join_after = after_pending && i + 1 == n_segs;
|
|
// Fast path: the whole run folds to one shaped leaf, unless
|
|
// pseudo-content word leaves must share its lines.
|
|
if !join_before && !join_after {
|
|
if let Some(item) = engine.try_build_run(tree, id, run, styles) {
|
|
let leaf = taffy_tree.new_leaf_with_context(run_leaf_style(), item).ok()?;
|
|
ifc_items.runs.entry(id).or_default().push(item);
|
|
child_ids.push(leaf);
|
|
continue;
|
|
}
|
|
}
|
|
let mut atoms: Vec<taffy::NodeId> = Vec::new();
|
|
if join_before {
|
|
atoms.extend(before_leaves.iter().copied());
|
|
before_pending = false;
|
|
}
|
|
for &rc in run {
|
|
atoms.extend(build_any(tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles));
|
|
}
|
|
if join_after {
|
|
atoms.extend(after_leaves.iter().copied());
|
|
after_pending = false;
|
|
}
|
|
if atoms.is_empty() {
|
|
// Whitespace-only run between blocks: no anonymous box.
|
|
continue;
|
|
}
|
|
let wrapper = taffy_tree
|
|
.new_with_children(
|
|
run_wrapper_style(style, has_text_strut),
|
|
&atoms,
|
|
)
|
|
.ok()?;
|
|
child_ids.push(wrapper);
|
|
}
|
|
}
|
|
}
|
|
// Pseudo content that found no adjacent run to join.
|
|
if before_pending {
|
|
let wrapper = taffy_tree
|
|
.new_with_children(run_wrapper_style(style, true), &before_leaves)
|
|
.ok()?;
|
|
child_ids.insert(0, wrapper);
|
|
}
|
|
if after_pending {
|
|
let wrapper = taffy_tree
|
|
.new_with_children(run_wrapper_style(style, true), &after_leaves)
|
|
.ok()?;
|
|
child_ids.push(wrapper);
|
|
}
|
|
if after_block {
|
|
child_ids.extend(after_leaves.iter().copied());
|
|
}
|
|
for cid in out_of_flow {
|
|
child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles));
|
|
}
|
|
|
|
let taffy_id = if child_ids.is_empty() {
|
|
taffy_tree.new_leaf(taffy_style).ok()?
|
|
} else {
|
|
taffy_tree.new_with_children(taffy_style, &child_ids).ok()?
|
|
};
|
|
id_map.insert(taffy_id, id);
|
|
Some(taffy_id)
|
|
}
|
|
|
|
fn needs_column_flex_text_fit_content_cap(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> bool {
|
|
if style.display != crate::Display::Flex
|
|
|| style.internal_flex_container
|
|
|| style.is_inline_block
|
|
|| style.float.is_some()
|
|
|| matches!(style.position, Some(taffy::Position::Absolute))
|
|
|| style.aspect_ratio.is_some()
|
|
|| !matches!(
|
|
(style.width, style.min_width, style.max_width),
|
|
(
|
|
crate::Dimension::Auto,
|
|
crate::Dimension::Auto,
|
|
crate::Dimension::Auto
|
|
)
|
|
)
|
|
|| style.size_expressions[0].is_some()
|
|
|| style.size_expressions[2].is_some()
|
|
|| style.size_expressions[4].is_some()
|
|
|| style.padding.left != 0.0
|
|
|| style.padding.right != 0.0
|
|
|| style.padding_percent[1].is_some()
|
|
|| style.padding_percent[3].is_some()
|
|
|| style.border.left != 0.0
|
|
|| style.border.right != 0.0
|
|
|| style.margin.left != 0.0
|
|
|| style.margin.right != 0.0
|
|
|| style.margin_auto[1]
|
|
|| style.margin_auto[3]
|
|
|| style.margin_percent[1].is_some()
|
|
|| style.margin_percent[3].is_some()
|
|
|| style.margin_relative[1].is_some()
|
|
|| style.margin_relative[3].is_some()
|
|
|| style.margin_expressions[1].is_some()
|
|
|| style.margin_expressions[3].is_some()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let has_direct_text = tree.children(id).into_iter().any(|child| {
|
|
tree.get_node(child).map_or(false, |node| {
|
|
matches!(
|
|
&node.data,
|
|
obscura_dom::tree::NodeData::Text { contents }
|
|
if !contents.trim().is_empty()
|
|
)
|
|
})
|
|
});
|
|
if !has_direct_text {
|
|
return false;
|
|
}
|
|
|
|
let mut parent = tree.get_node(id).and_then(|node| node.parent);
|
|
let parent_style = loop {
|
|
let Some(parent_id) = parent else {
|
|
return false;
|
|
};
|
|
let Some(parent_style) = styles.get(&parent_id) else {
|
|
return false;
|
|
};
|
|
if parent_style.display_contents {
|
|
parent = tree.get_node(parent_id).and_then(|node| node.parent);
|
|
continue;
|
|
}
|
|
break parent_style;
|
|
};
|
|
if parent_style.display != crate::Display::Flex
|
|
|| parent_style.internal_flex_container
|
|
|| !matches!(
|
|
parent_style.flex_direction,
|
|
Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse)
|
|
)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
style
|
|
.align_self
|
|
.unwrap_or(parent_style.align_items.unwrap_or(taffy::AlignItems::STRETCH))
|
|
!= taffy::AlignSelf::STRETCH
|
|
}
|
|
|
|
/// Expand `display: contents` wrappers so their children partition into the
|
|
/// caller's segment list as if they were direct children (CSS Display 3).
|
|
fn flatten_contents_children(
|
|
tree: &DomTree,
|
|
children: &[NodeId],
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
out: &mut Vec<NodeId>,
|
|
) {
|
|
for &cid in children {
|
|
let splices = styles.get(&cid).map(|s| s.display_contents && s.display != crate::Display::None).unwrap_or(false);
|
|
if splices {
|
|
let kids = tree.children(cid);
|
|
flatten_contents_children(tree, &kids, styles, out);
|
|
} else {
|
|
out.push(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Does an inline contribute only in-flow block children?
|
|
///
|
|
/// Such an inline has empty before/after inline fragments once CSS performs
|
|
/// the block-in-inline split, so its block children can participate directly
|
|
/// in the containing block's segment construction. This is deliberately
|
|
/// narrower than a complete ib-split: mixed inline/block children still need
|
|
/// fragment boxes to preserve decorations. A relative inline with no offsets
|
|
/// is safe here provided it does not establish the containing block for an
|
|
/// absolutely positioned descendant.
|
|
fn inline_wraps_only_in_flow_blocks(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> bool {
|
|
let Some(style) = styles.get(&id) else {
|
|
return false;
|
|
};
|
|
if style.display != crate::Display::Inline
|
|
|| style.is_inline_block
|
|
|| style.before_pseudo.is_some()
|
|
|| style.after_pseudo.is_some()
|
|
|| style.float.is_some()
|
|
|| matches!(style.position, Some(taffy::Position::Absolute))
|
|
|| (style.position.is_some()
|
|
&& (style.inset.iter().any(Option::is_some)
|
|
|| style.inset_expressions.iter().any(Option::is_some)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let mut saw_block = false;
|
|
for cid in tree.children(id) {
|
|
let Some(node) = tree.get_node(cid) else {
|
|
continue;
|
|
};
|
|
if let obscura_dom::tree::NodeData::Text { contents } = &node.data {
|
|
if !contents.trim().is_empty() {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if !node.is_element() {
|
|
continue;
|
|
}
|
|
let Some(child) = styles.get(&cid) else {
|
|
return false;
|
|
};
|
|
if child.display == crate::Display::None {
|
|
continue;
|
|
}
|
|
if matches!(child.position, Some(taffy::Position::Absolute)) || child.float.is_some() {
|
|
return false;
|
|
}
|
|
if child.display_contents {
|
|
if !inline_wraps_only_in_flow_blocks(tree, cid, styles) {
|
|
return false;
|
|
}
|
|
saw_block = true;
|
|
} else if child.display == crate::Display::Block {
|
|
saw_block = true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
saw_block
|
|
}
|
|
|
|
/// Recursively splice `display:contents`, decoration-free inline wrappers, and
|
|
/// inline wrappers whose only generated flow content is block-level into a
|
|
/// block container's effective child list. Descendants already carry inherited
|
|
/// computed text styles, so removing these wrappers preserves shaping while
|
|
/// exposing block-in-inline descendants early enough for anonymous block
|
|
/// construction.
|
|
fn flatten_boxless_inline_children(
|
|
tree: &DomTree,
|
|
children: &[NodeId],
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
out: &mut Vec<NodeId>,
|
|
) {
|
|
for &cid in children {
|
|
let display_contents = styles
|
|
.get(&cid)
|
|
.map(|style| style.display_contents && style.display != crate::Display::None)
|
|
.unwrap_or(false);
|
|
if display_contents
|
|
|| is_flattenable_inline(tree, cid, styles)
|
|
|| inline_wraps_only_in_flow_blocks(tree, cid, styles)
|
|
{
|
|
let kids = tree.children(cid);
|
|
flatten_boxless_inline_children(tree, &kids, styles, out);
|
|
} else {
|
|
out.push(cid);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Style for a single-leaf inline run: a block-level box that fills the
|
|
/// containing block's width, with height from the shaped text (measure fn).
|
|
fn run_leaf_style() -> taffy::Style {
|
|
taffy::Style {
|
|
size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() },
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Style for an anonymous inline-run wrapper: a full-width block-level box
|
|
/// whose interior is the flex-wrap approximation of an inline formatting
|
|
/// context. The parent's `text-align` moves the run's line content via
|
|
/// justify-content, exactly as the old whole-container
|
|
/// promotion did, but scoped to the run so sibling blocks stay full width.
|
|
fn run_wrapper_style(
|
|
parent: &crate::LayoutStyle,
|
|
has_text_strut: bool,
|
|
) -> taffy::Style {
|
|
let justify = match parent.text_align {
|
|
Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END),
|
|
Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER),
|
|
_ => None,
|
|
};
|
|
let line_height = if has_text_strut {
|
|
crate::inline::used_line_height(parent).max(0.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
justify_content: justify,
|
|
size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() },
|
|
// Every CSS line box starts with the parent's font/line-height strut,
|
|
// even when its only atomic inline is shorter (or zero-sized).
|
|
min_size: taffy::Size {
|
|
width: taffy::style::Dimension::auto(),
|
|
height: taffy::style::Dimension::length(line_height),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Approximate `float: left|right` without real per-line reflow (which
|
|
/// taffy's block/flex/grid modes do not provide): place the float alongside
|
|
/// the flow siblings that follow it until their estimated height reaches the
|
|
/// float's estimated bottom, a matching `clear` is encountered, or another
|
|
/// float begins, then let everything from there on revert to normal full-width
|
|
/// flow.
|
|
///
|
|
/// This is not a general CSS float implementation (a float taller than its
|
|
/// estimated flow zone won't reflow correctly), but it directly targets the overwhelmingly
|
|
/// common real-world shape: a floated image or infobox near the top of an
|
|
/// article, sitting beside the intro text, with the rest of the content
|
|
/// running full width once normal flow passes the float.
|
|
/// Rough height budget for a float with no explicit size and no images
|
|
/// (an icon-only or empty float, rare in practice): enough for a couple of
|
|
/// lines of caption-sized text without being so generous it drags in a
|
|
/// whole section the way an unbounded zone did.
|
|
const DEFAULT_FLOAT_HEIGHT_ESTIMATE: f32 = 200.0;
|
|
|
|
/// Estimate a float's rendered height in CSS px, for bounding how many flow
|
|
/// siblings should wrap alongside it (see `build_children_with_float_zone`).
|
|
/// Real layout hasn't run yet at this point (we're still building the taffy
|
|
/// tree), so this can only approximate: prefer an explicit height on the
|
|
/// float itself, else sum the explicit heights of descendant `<img>`s (the
|
|
/// common `<figure><img height=".."><figcaption>` thumbnail shape) plus a
|
|
/// text-based estimate of the float's own content (the common tall-infobox
|
|
/// shape, where the height comes from many rows of text rather than a
|
|
/// single image).
|
|
fn estimate_float_height(tree: &DomTree, float_id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> f32 {
|
|
if let Some(crate::Dimension::Px(h)) = styles.get(&float_id).map(|s| s.height) {
|
|
return h;
|
|
}
|
|
let image_height: f32 = tree
|
|
.descendants(float_id)
|
|
.into_iter()
|
|
.filter(|&id| {
|
|
tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())).as_deref() == Some("img")
|
|
})
|
|
.filter_map(|id| match styles.get(&id).map(|s| s.height) {
|
|
Some(crate::Dimension::Px(h)) => Some(h),
|
|
_ => None,
|
|
})
|
|
.sum();
|
|
const ASSUMED_FLOAT_WIDTH: f32 = 280.0;
|
|
let text_height = estimate_text_height(tree, float_id, styles, ASSUMED_FLOAT_WIDTH);
|
|
// Flattened character count misses forced rows. A sidebar list with twenty
|
|
// short `<li>`s or an infobox table with many `<tr>`s is much taller than
|
|
// the same text treated as one wrapping paragraph. Add one line for each
|
|
// structural row; the continuous-text estimate still accounts for extra
|
|
// wrapping within those rows.
|
|
let structural_height: f32 = tree
|
|
.descendants(float_id)
|
|
.into_iter()
|
|
.filter(|&id| {
|
|
tree.get_node(id)
|
|
.and_then(|node| node.as_element().map(|element| element.local.to_string()))
|
|
.map(|local| {
|
|
matches!(
|
|
local.as_str(),
|
|
"li" | "tr" | "dt" | "dd" | "p" | "figcaption" | "h1" | "h2" | "h3" | "h4"
|
|
| "h5" | "h6"
|
|
)
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.map(|id| styles.get(&id).and_then(|style| style.font_size).unwrap_or(16.0) * 1.2)
|
|
.sum();
|
|
(image_height + text_height + structural_height).max(DEFAULT_FLOAT_HEIGHT_ESTIMATE)
|
|
}
|
|
|
|
/// Estimate how tall `id`'s text content would render at `assumed_width`,
|
|
/// using the same average-character-width heuristic as the layout-only
|
|
/// (non-`paint`) text sizing fallback. Used only to bound the float-wrapping
|
|
/// zone (see `build_children_with_float_zone`), where the real available
|
|
/// width is not yet known, so this is deliberately approximate.
|
|
fn estimate_text_height(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>, assumed_width: f32) -> f32 {
|
|
let char_count = tree.text_content(id).chars().filter(|c| !c.is_whitespace()).count() as f32;
|
|
if char_count == 0.0 {
|
|
return 0.0;
|
|
}
|
|
let fsize = styles.get(&id).and_then(|s| s.font_size).unwrap_or(16.0);
|
|
const AVG_CHAR_WIDTH_EM: f32 = 0.55;
|
|
let chars_per_line = (assumed_width / (fsize * AVG_CHAR_WIDTH_EM)).max(1.0);
|
|
let lines = (char_count / chars_per_line).ceil().max(1.0);
|
|
lines * fsize * 1.2 + 16.0
|
|
}
|
|
|
|
/// Estimate the normal-flow height consumed by one sibling alongside a float.
|
|
/// Text alone is insufficient for image grids and fixed-height boxes, which
|
|
/// otherwise cost zero budget and remain squeezed beside a float long after
|
|
/// they should have passed its bottom.
|
|
fn estimate_flow_sibling_height(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
assumed_width: f32,
|
|
) -> f32 {
|
|
let style = styles.get(&id);
|
|
if style.map(|style| style.display == crate::Display::None).unwrap_or(false)
|
|
|| style
|
|
.and_then(|style| style.position)
|
|
.map(|position| position == taffy::Position::Absolute)
|
|
.unwrap_or(false)
|
|
{
|
|
return 0.0;
|
|
}
|
|
let explicit_height = match style.map(|style| style.height) {
|
|
Some(crate::Dimension::Px(height)) => height.max(0.0),
|
|
_ => 0.0,
|
|
};
|
|
let descendant_image_height: f32 = tree
|
|
.descendants(id)
|
|
.into_iter()
|
|
.filter(|&descendant| {
|
|
tree.get_node(descendant)
|
|
.and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img"))
|
|
.unwrap_or(false)
|
|
})
|
|
.filter_map(|descendant| match styles.get(&descendant).map(|style| style.height) {
|
|
Some(crate::Dimension::Px(height)) => Some(height.max(0.0)),
|
|
_ => None,
|
|
})
|
|
.sum();
|
|
let own_image_height = if tree
|
|
.get_node(id)
|
|
.and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img"))
|
|
.unwrap_or(false)
|
|
{
|
|
explicit_height
|
|
} else {
|
|
0.0
|
|
};
|
|
let content_height = estimate_text_height(tree, id, styles, assumed_width)
|
|
.max(explicit_height)
|
|
.max(descendant_image_height + own_image_height);
|
|
let margins = style.map(|style| (style.margin.top + style.margin.bottom).max(0.0)).unwrap_or(0.0);
|
|
content_height + margins
|
|
}
|
|
|
|
/// Largest definite (px) width among `id` and its descendants. Used to cap an
|
|
/// auto-width floated figure: a Wikipedia thumbnail is `<figure ...><img
|
|
/// width=250><figcaption>long text</figcaption></figure>`, and without a cap
|
|
/// the caption's unwrapped one-line max-content width (~700px) sizes the float
|
|
/// and starves the adjacent flow column to nothing. Real browsers size the
|
|
/// figure to the image (display:table) and wrap the caption; the image's
|
|
/// definite width is the bound that reproduces that.
|
|
fn max_definite_descendant_width(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> Option<f32> {
|
|
let mut best: Option<f32> = None;
|
|
for d in tree.descendants(id) {
|
|
if let Some(crate::Dimension::Px(w)) = styles.get(&d).map(|s| s.width.clone()) {
|
|
if w > 0.0 {
|
|
best = Some(best.map_or(w, |b: f32| b.max(w)));
|
|
}
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
fn establishes_block_formatting_context(style: &crate::LayoutStyle) -> bool {
|
|
matches!(style.display, crate::Display::Flex | crate::Display::Grid)
|
|
|| effective_container_type(style) != crate::ContainerType::Normal
|
|
|| style.flow_root
|
|
|| style.overflow_hidden
|
|
|| style.is_inline_block
|
|
|| style.float.is_some()
|
|
|| matches!(style.position, Some(taffy::Position::Absolute))
|
|
}
|
|
|
|
fn clear_matches_float_sides(clear: crate::Clear, has_left: bool, has_right: bool) -> bool {
|
|
(!has_left || matches!(clear, crate::Clear::Left | crate::Clear::Both))
|
|
&& (!has_right || matches!(clear, crate::Clear::Right | crate::Clear::Both))
|
|
}
|
|
|
|
fn has_deferred_or_auto_margin(style: &crate::LayoutStyle) -> bool {
|
|
style.margin_auto.iter().any(|value| *value)
|
|
|| style.margin_percent.iter().any(Option::is_some)
|
|
|| style.margin_relative.iter().any(Option::is_some)
|
|
|| style.margin_expressions.iter().any(Option::is_some)
|
|
}
|
|
|
|
fn is_structural_native_clear_box(
|
|
tree: &DomTree,
|
|
id: NodeId,
|
|
style: &crate::LayoutStyle,
|
|
) -> bool {
|
|
style.display == crate::Display::Block
|
|
&& !style.display_contents
|
|
&& !style.is_table_box
|
|
&& !style.is_inline_block
|
|
&& !style.flow_root
|
|
&& !style.overflow_hidden
|
|
&& effective_container_type(style) == crate::ContainerType::Normal
|
|
&& style.float.is_none()
|
|
&& style.clear.is_some()
|
|
&& !matches!(style.position, Some(taffy::Position::Absolute))
|
|
&& tree.text_content(id).trim().is_empty()
|
|
&& style.before_pseudo.is_none()
|
|
&& style.after_pseudo.is_none()
|
|
}
|
|
|
|
fn is_structural_native_float_pseudo(style: &crate::LayoutStyle) -> bool {
|
|
style.display != crate::Display::None
|
|
&& style.display != crate::Display::Inline
|
|
&& !style.display_contents
|
|
&& !style.is_inline_block
|
|
&& (!style.flow_root || style.is_table_box)
|
|
&& !style.overflow_hidden
|
|
&& effective_container_type(style) == crate::ContainerType::Normal
|
|
&& style.float.is_none()
|
|
&& !matches!(style.position, Some(taffy::Position::Absolute))
|
|
&& style
|
|
.before_content
|
|
.as_deref()
|
|
.map_or(true, |content| content.trim().is_empty())
|
|
}
|
|
|
|
/// Native taffy floats are currently sound for a deliberately small structural
|
|
/// subset: a flat block band made only of definite-width direct floats plus
|
|
/// empty generated/direct clearance boxes. Text flow, transparent wrappers,
|
|
/// shrink-to-fit floats, independent formatting contexts, and tables retain
|
|
/// the synthetic float-zone path.
|
|
fn can_use_native_float_band(
|
|
tree: &DomTree,
|
|
parent_style: &crate::LayoutStyle,
|
|
dom_children: &[NodeId],
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> bool {
|
|
if parent_style.display != crate::Display::Block
|
|
|| parent_style.internal_flex_container
|
|
|| parent_style.is_inline_block
|
|
|| parent_style.is_table_box
|
|
|| parent_style.float.is_some()
|
|
|| matches!(parent_style.position, Some(taffy::Position::Absolute))
|
|
|| !matches!(
|
|
parent_style.width,
|
|
crate::Dimension::Px(_) | crate::Dimension::Percent(_)
|
|
)
|
|
|| parent_style.size_expressions[0].is_some()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let mut has_left = false;
|
|
let mut has_right = false;
|
|
let mut saw_float = false;
|
|
let mut saw_clear_after_floats = false;
|
|
|
|
if let Some(before) = parent_style.before_pseudo.as_deref() {
|
|
if !is_structural_native_float_pseudo(before) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for &id in dom_children {
|
|
let Some(node) = tree.get_node(id) else { continue };
|
|
if !node.is_element() {
|
|
if !tree.text_content(id).trim().is_empty() {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
let Some(style) = styles.get(&id) else {
|
|
return false;
|
|
};
|
|
if style.display == crate::Display::None {
|
|
continue;
|
|
}
|
|
|
|
if let Some(side) = style.float {
|
|
if saw_clear_after_floats
|
|
|| style.display_contents
|
|
|| style.is_table_box
|
|
|| matches!(style.position, Some(taffy::Position::Absolute))
|
|
|| !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_))
|
|
|| style.size_expressions[0].is_some()
|
|
|| has_deferred_or_auto_margin(style)
|
|
{
|
|
return false;
|
|
}
|
|
saw_float = true;
|
|
has_left |= side == crate::Float::Left;
|
|
has_right |= side == crate::Float::Right;
|
|
} else if is_structural_native_clear_box(tree, id, style) {
|
|
if !saw_float {
|
|
return false;
|
|
}
|
|
saw_clear_after_floats |= clear_matches_float_sides(
|
|
style.clear.expect("structural clear has a side"),
|
|
has_left,
|
|
has_right,
|
|
);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if let Some(after) = parent_style.after_pseudo.as_deref() {
|
|
if !is_structural_native_float_pseudo(after) {
|
|
return false;
|
|
}
|
|
if let Some(clear) = after.clear {
|
|
saw_clear_after_floats |= clear_matches_float_sides(clear, has_left, has_right);
|
|
}
|
|
}
|
|
|
|
// Taffy represents overflow clipping as a real BFC root. Other Obscura
|
|
// BFC markers do not yet have a distinct taffy-side representation, so
|
|
// they are intentionally not used as an escape-safety signal here.
|
|
let parent_is_native_bfc = parent_style.overflow_hidden;
|
|
saw_float && (saw_clear_after_floats || parent_is_native_bfc)
|
|
}
|
|
|
|
fn set_native_float_clear(
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
node: taffy::NodeId,
|
|
style: &crate::LayoutStyle,
|
|
generated_pseudo: bool,
|
|
) {
|
|
let Ok(current) = taffy_tree.style(node) else { return };
|
|
let mut native = current.clone();
|
|
native.float = match style.float {
|
|
Some(crate::Float::Left) => taffy::style::Float::Left,
|
|
Some(crate::Float::Right) => taffy::style::Float::Right,
|
|
None => taffy::style::Float::None,
|
|
};
|
|
native.clear = match style.clear {
|
|
Some(crate::Clear::Left) => taffy::style::Clear::Left,
|
|
Some(crate::Clear::Right) => taffy::style::Clear::Right,
|
|
Some(crate::Clear::Both) => taffy::style::Clear::Both,
|
|
None => taffy::style::Clear::None,
|
|
};
|
|
if generated_pseudo && style.is_table_box {
|
|
// Bootstrap-style clearfix pseudos use display:table only to generate
|
|
// an empty block formatting box. Taffy's independent table-item clear
|
|
// path is incomplete, while its same-BFC block clear path is correct.
|
|
native.display = taffy::style::Display::Block;
|
|
native.item_is_table = false;
|
|
}
|
|
let _ = taffy_tree.set_style(node, native);
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_children_with_native_float_band(
|
|
tree: &DomTree,
|
|
parent_id: NodeId,
|
|
parent_style: &crate::LayoutStyle,
|
|
dom_children: &[NodeId],
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let mut result = Vec::new();
|
|
for (kind, pseudo) in [
|
|
(GeneratedBoxKind::Before, parent_style.before_pseudo.as_deref()),
|
|
(GeneratedBoxKind::After, parent_style.after_pseudo.as_deref()),
|
|
] {
|
|
if kind == GeneratedBoxKind::After {
|
|
for &id in dom_children {
|
|
let Some(style) = styles.get(&id) else { continue };
|
|
for node in build_any(
|
|
tree, id, taffy_tree, id_map, words, engine, ifc_items, styles,
|
|
) {
|
|
set_native_float_clear(taffy_tree, node, style, false);
|
|
result.push(node);
|
|
}
|
|
}
|
|
}
|
|
if let Some((nodes, _)) =
|
|
build_in_flow_pseudo(parent_id, kind, pseudo, taffy_tree, words, ifc_items)
|
|
{
|
|
if let Some(style) = pseudo {
|
|
for node in nodes {
|
|
set_native_float_clear(taffy_tree, node, style, true);
|
|
result.push(node);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn build_children_with_float_zone(
|
|
tree: &DomTree,
|
|
parent_id: NodeId,
|
|
dom_children: &[NodeId],
|
|
taffy_tree: &mut TaffyTree<usize>,
|
|
id_map: &mut HashMap<taffy::NodeId, NodeId>,
|
|
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
|
|
engine: &mut crate::inline::TextEngine,
|
|
ifc_items: &mut IfcRegistry,
|
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
|
) -> Vec<taffy::NodeId> {
|
|
let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false);
|
|
|
|
// A block made entirely from inline-ish flow content and two or more
|
|
// right floats is the classic utility/navigation bar. Right floats are
|
|
// placed from the inline end inward, so their visual order is the reverse
|
|
// of source order, while ordinary inline content keeps filling from the
|
|
// start of the same band. Serializing each encountered float into its own
|
|
// synthetic row reverses those two groups and can leave the entire bar
|
|
// shrink-wrapped at the start.
|
|
//
|
|
// Model this bounded one-band case as [flow | reversed right-float group].
|
|
// A nested group preserves every float's authored margins; only the
|
|
// anonymous group receives the auto margin used to represent the free
|
|
// space between the two sides.
|
|
let right_floats: Vec<NodeId> = dom_children
|
|
.iter()
|
|
.copied()
|
|
.filter(|cid| {
|
|
styles.get(cid).map_or(false, |style| {
|
|
style.float == Some(crate::Float::Right)
|
|
&& style.display != crate::Display::None
|
|
})
|
|
})
|
|
.collect();
|
|
let has_left_float = dom_children.iter().any(|cid| {
|
|
styles.get(cid).map_or(false, |style| {
|
|
style.float == Some(crate::Float::Left)
|
|
&& style.display != crate::Display::None
|
|
})
|
|
});
|
|
let flow_is_inline = dom_children.iter().all(|cid| {
|
|
let Some(node) = tree.get_node(*cid) else {
|
|
return true;
|
|
};
|
|
if !node.is_element() || is_float(*cid) {
|
|
return true;
|
|
}
|
|
styles
|
|
.get(cid)
|
|
.map_or(true, |style| style.display != crate::Display::Block)
|
|
});
|
|
if right_floats.len() >= 2 && !has_left_float && flow_is_inline {
|
|
// Removing out-of-flow items must not remove the one collapsible
|
|
// space between the inline items on either side of them. Collapse
|
|
// any run of formatting whitespace to one representative node, while
|
|
// dropping leading/trailing whitespace at the band edges.
|
|
let mut flow_dom = Vec::new();
|
|
let mut pending_whitespace = None;
|
|
let mut has_flow_content = false;
|
|
for &cid in dom_children {
|
|
if is_float(cid) {
|
|
continue;
|
|
}
|
|
let is_whitespace = tree.get_node(cid).map_or(false, |node| {
|
|
!node.is_element() && tree.text_content(cid).trim().is_empty()
|
|
});
|
|
if is_whitespace {
|
|
if has_flow_content && pending_whitespace.is_none() {
|
|
pending_whitespace = Some(cid);
|
|
}
|
|
continue;
|
|
}
|
|
if has_flow_content {
|
|
if let Some(whitespace) = pending_whitespace.take() {
|
|
flow_dom.push(whitespace);
|
|
}
|
|
}
|
|
flow_dom.push(cid);
|
|
has_flow_content = true;
|
|
}
|
|
let mut row_children: Vec<taffy::NodeId> = flow_dom
|
|
.into_iter()
|
|
.flat_map(|cid| {
|
|
build_any(
|
|
tree,
|
|
cid,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
)
|
|
})
|
|
.collect();
|
|
let right_children: Vec<taffy::NodeId> = right_floats
|
|
.iter()
|
|
.rev()
|
|
.filter_map(|cid| {
|
|
build(
|
|
tree,
|
|
*cid,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
)
|
|
})
|
|
.collect();
|
|
if !row_children.is_empty() && !right_children.is_empty() {
|
|
let right_group_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
margin: taffy::Rect {
|
|
top: taffy::style::LengthPercentageAuto::length(0.0),
|
|
right: taffy::style::LengthPercentageAuto::length(0.0),
|
|
bottom: taffy::style::LengthPercentageAuto::length(0.0),
|
|
left: taffy::style::LengthPercentageAuto::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
if let Ok(right_group) =
|
|
taffy_tree.new_with_children(right_group_style, &right_children)
|
|
{
|
|
row_children.push(right_group);
|
|
let row_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
size: taffy::Size {
|
|
width: taffy::Dimension::percent(1.0),
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
if let Ok(row) =
|
|
taffy_tree.new_with_children(row_style, &row_children)
|
|
{
|
|
return vec![row];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(float_idx) = dom_children.iter().position(|&cid| is_float(cid)) else {
|
|
return dom_children.iter().flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect();
|
|
};
|
|
|
|
let mut result: Vec<taffy::NodeId> = dom_children[..float_idx]
|
|
.iter()
|
|
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles))
|
|
.collect();
|
|
|
|
let float_side = styles.get(&dom_children[float_idx]).and_then(|s| s.float);
|
|
|
|
// Opposing header floats share one float band even when an empty legacy
|
|
// compatibility box sits between them. This is the classic left-logo /
|
|
// right-tagline header: serializing the two synthetic rows doubles the
|
|
// header height and pushes every later box down. Real float placement
|
|
// scans the same BFC band and puts the second float against the opposite
|
|
// edge when both margin boxes fit.
|
|
let is_empty_bridge = |cid: NodeId| {
|
|
let Some(node) = tree.get_node(cid) else { return true };
|
|
if !node.is_element() {
|
|
return tree.text_content(cid).trim().is_empty();
|
|
}
|
|
let style = styles.get(&cid);
|
|
let no_size = style
|
|
.map(|style| {
|
|
matches!(style.width, crate::Dimension::Auto)
|
|
&& matches!(style.height, crate::Dimension::Auto)
|
|
&& matches!(style.min_width, crate::Dimension::Auto)
|
|
&& matches!(style.min_height, crate::Dimension::Auto)
|
|
&& matches!(style.max_width, crate::Dimension::Auto)
|
|
&& matches!(style.max_height, crate::Dimension::Auto)
|
|
&& style.margin == crate::Edges::default()
|
|
&& style.padding == crate::Edges::default()
|
|
&& style
|
|
.padding_percent
|
|
.iter()
|
|
.all(|value| value.is_none())
|
|
&& style.border == crate::Edges::default()
|
|
&& style.before_pseudo.is_none()
|
|
&& style.after_pseudo.is_none()
|
|
})
|
|
.unwrap_or(true);
|
|
no_size && tree.text_content(cid).trim().is_empty()
|
|
};
|
|
let mut opposite_idx = float_idx + 1;
|
|
while opposite_idx < dom_children.len() && is_empty_bridge(dom_children[opposite_idx]) {
|
|
opposite_idx += 1;
|
|
}
|
|
let opposite_side = dom_children
|
|
.get(opposite_idx)
|
|
.and_then(|cid| styles.get(cid))
|
|
.and_then(|style| style.float);
|
|
if opposite_side.is_some() && opposite_side != float_side {
|
|
let first = build(
|
|
tree,
|
|
dom_children[float_idx],
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
);
|
|
let second = build(
|
|
tree,
|
|
dom_children[opposite_idx],
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
);
|
|
let row_children: Vec<taffy::NodeId> = match float_side {
|
|
Some(crate::Float::Left) => [first, second].into_iter().flatten().collect(),
|
|
_ => [second, first].into_iter().flatten().collect(),
|
|
};
|
|
let row_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
justify_content: Some(taffy::JustifyContent::SPACE_BETWEEN),
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
size: taffy::Size {
|
|
width: taffy::Dimension::percent(1.0),
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) {
|
|
result.push(row);
|
|
}
|
|
result.extend(
|
|
dom_children[opposite_idx + 1..]
|
|
.iter()
|
|
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)),
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// A run of two or more consecutively floated siblings (the classic
|
|
// float-grid idiom: several `float:left; width:N%` boxes forming columns,
|
|
// e.g. craigslist's `.sites .box{float:left;width:23%}` site directory) is
|
|
// not the single-float-beside-flow shape handled below: real float layout
|
|
// places the run side by side, wrapping to a new line when the row fills.
|
|
// Model the run as a wrapping flex row. Whitespace-only text between the
|
|
// floats does not break the run.
|
|
let mut run_end = float_idx + 1;
|
|
let mut float_count = 1usize;
|
|
while run_end < dom_children.len() {
|
|
let cid = dom_children[run_end];
|
|
if styles.get(&cid).and_then(|s| s.float) == float_side {
|
|
float_count += 1;
|
|
run_end += 1;
|
|
} else if tree.get_node(cid).map_or(false, |n| !n.is_element())
|
|
&& tree.text_content(cid).trim().is_empty()
|
|
{
|
|
run_end += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if float_count >= 2 {
|
|
let mut run_children: Vec<taffy::NodeId> = dom_children[float_idx..run_end]
|
|
.iter()
|
|
// Formatting whitespace between floats does not generate an
|
|
// in-flow flex item or consume horizontal space.
|
|
.filter(|&&cid| styles.get(&cid).and_then(|s| s.float) == float_side)
|
|
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles))
|
|
.collect();
|
|
// A common navigation-bar shape is a run of left floats followed by
|
|
// one right float. The right float still scans the current float band:
|
|
// it does not start a new row merely because multiple left floats
|
|
// precede it. Keep the run in one wrapping row and use an auto inline
|
|
// margin to reserve all remaining space before the opposing float.
|
|
//
|
|
// This stays deliberately narrower than a general float manager. In
|
|
// particular, multiple right floats have reverse source-order
|
|
// placement semantics and need their own representation.
|
|
let trailing_right = (float_side == Some(crate::Float::Left)
|
|
&& dom_children
|
|
.get(run_end)
|
|
.and_then(|cid| styles.get(cid))
|
|
.and_then(|style| style.float)
|
|
== Some(crate::Float::Right))
|
|
.then(|| dom_children[run_end]);
|
|
if let Some(right_dom) = trailing_right {
|
|
let right = build(
|
|
tree,
|
|
right_dom,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
);
|
|
if let Some(right) = right {
|
|
if let Ok(current) = taffy_tree.style(right) {
|
|
let mut pushed_right = current.clone();
|
|
pushed_right.margin.left =
|
|
taffy::style::LengthPercentageAuto::auto();
|
|
let _ = taffy_tree.set_style(right, pushed_right);
|
|
}
|
|
run_children.push(right);
|
|
let row_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
size: taffy::Size {
|
|
width: taffy::Dimension::percent(1.0),
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
if let Ok(row) = taffy_tree.new_with_children(row_style, &run_children) {
|
|
result.push(row);
|
|
}
|
|
result.extend(build_children_with_float_zone(
|
|
tree,
|
|
parent_id,
|
|
&dom_children[run_end + 1..],
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
));
|
|
return result;
|
|
}
|
|
}
|
|
let row_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
flex_wrap: taffy::FlexWrap::Wrap,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
..Default::default()
|
|
};
|
|
if let Ok(row) = taffy_tree.new_with_children(row_style, &run_children) {
|
|
result.push(row);
|
|
}
|
|
result.extend(build_children_with_float_zone(
|
|
tree, parent_id, &dom_children[run_end..], taffy_tree, id_map, words, engine, ifc_items,
|
|
styles,
|
|
));
|
|
return result;
|
|
}
|
|
|
|
// Stop growing the zone once the flow siblings collected so far would
|
|
// already fill (an estimate of) the float's own height: real float
|
|
// reflow ends when normal-flow content passes the float's bottom edge,
|
|
// Headings do not terminate a CSS float's influence by themselves.
|
|
// Without this, a short floated thumbnail (a few hundred px) dragged an
|
|
// entire multi-paragraph section into a narrow flow column alongside it
|
|
// — visibly wrong wrapping plus a large empty gap once the (much
|
|
// shorter) float ran out, both from treating "next heading" as the only
|
|
// bound. The estimate is necessarily rough (actual available width is a
|
|
// taffy layout result we don't have yet at tree-build time), but even an
|
|
// approximate bound beats an unbounded one.
|
|
let float_height_budget = estimate_float_height(tree, dom_children[float_idx], styles);
|
|
const ASSUMED_FLOW_WIDTH: f32 = 500.0;
|
|
// `clear` on a sibling ends the zone: the cleared element moves below the
|
|
// float (the clearfix idiom), so it must not join the flow column beside it.
|
|
let clears_this_float = |cid: NodeId| {
|
|
let Some(c) = styles.get(&cid).and_then(|s| s.clear) else { return false };
|
|
match (float_side, c) {
|
|
(_, crate::Clear::Both) => true,
|
|
(Some(crate::Float::Left), crate::Clear::Left) => true,
|
|
(Some(crate::Float::Right), crate::Clear::Right) => true,
|
|
_ => false,
|
|
}
|
|
};
|
|
let mut zone_end = float_idx + 1;
|
|
let mut flow_height_estimate = 0.0f32;
|
|
while zone_end < dom_children.len()
|
|
&& !is_float(dom_children[zone_end])
|
|
&& !clears_this_float(dom_children[zone_end])
|
|
{
|
|
flow_height_estimate +=
|
|
estimate_flow_sibling_height(tree, dom_children[zone_end], styles, ASSUMED_FLOW_WIDTH);
|
|
zone_end += 1;
|
|
if flow_height_estimate >= float_height_budget {
|
|
break;
|
|
}
|
|
}
|
|
// The float itself is always an element (only elements get style
|
|
// entries, and `is_float` above required one), so a direct `build` call
|
|
// is correct here; only its flow siblings need the word-splitting `build_any`.
|
|
let float_taffy = build(tree, dom_children[float_idx], taffy_tree, id_map, words, engine, ifc_items, styles);
|
|
// Cap an auto-width float at its widest definite-width descendant so a long
|
|
// wrappable caption cannot inflate it to the caption's one-line max-content
|
|
// width and starve the flow column beside it (the Wikipedia-thumbnail /
|
|
// article-body-collapses-to-one-word-per-line bug). Only when the float is
|
|
// itself auto-width and actually contains such a box; text-only floats
|
|
// (pull quotes, sized infoboxes) are left to normal shrink-to-fit.
|
|
if let Some(float_id) = float_taffy {
|
|
let float_dom = dom_children[float_idx];
|
|
let float_auto = styles.get(&float_dom).map(|s| matches!(s.width, crate::Dimension::Auto)).unwrap_or(true);
|
|
if float_auto {
|
|
if let Some(w) = max_definite_descendant_width(tree, float_dom, styles) {
|
|
if let Ok(cur) = taffy_tree.style(float_id) {
|
|
let mut st = cur.clone();
|
|
// A little slack for the figure's own border/padding so the
|
|
// image is not clipped; the caption still wraps to ~image width.
|
|
st.max_size.width = taffy::Dimension::length(w + 12.0);
|
|
let _ = taffy_tree.set_style(float_id, st);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
match float_taffy {
|
|
Some(float_id) => {
|
|
let flow_column_style = taffy::Style {
|
|
display: taffy::style::Display::Block,
|
|
flex_grow: 1.0,
|
|
flex_shrink: 1.0,
|
|
flex_basis: taffy::Dimension::length(0.0),
|
|
min_size: taffy::Size { width: taffy::Dimension::length(0.0), height: taffy::Dimension::auto() },
|
|
..Default::default()
|
|
};
|
|
let flow_dom = &dom_children[float_idx + 1..zone_end];
|
|
let flow_column = if flow_dom.is_empty() {
|
|
taffy_tree.new_leaf(flow_column_style).ok()
|
|
} else {
|
|
// The zone is still an ordinary block formatting context:
|
|
// consecutive text/inline siblings must share inline runs,
|
|
// while block siblings stack. Building each sibling directly
|
|
// into a flex column makes every link a separate stretched
|
|
// row. Reuse the mixed-block builder, but leave this anonymous
|
|
// wrapper out of the DOM id map so it cannot overwrite the
|
|
// real parent's rectangle.
|
|
let mut flow_style = styles.get(&parent_id).cloned().unwrap_or_default();
|
|
flow_style.before_content = None;
|
|
flow_style.after_content = None;
|
|
let column = build_mixed_block(
|
|
tree,
|
|
parent_id,
|
|
&flow_style,
|
|
flow_column_style,
|
|
flow_dom,
|
|
taffy_tree,
|
|
id_map,
|
|
words,
|
|
engine,
|
|
ifc_items,
|
|
styles,
|
|
);
|
|
if let Some(column_id) = column {
|
|
id_map.remove(&column_id);
|
|
}
|
|
column
|
|
};
|
|
|
|
let row_style = taffy::Style {
|
|
display: taffy::style::Display::Flex,
|
|
flex_direction: taffy::FlexDirection::Row,
|
|
align_items: Some(taffy::AlignItems::FLEX_START),
|
|
size: taffy::Size {
|
|
width: taffy::Dimension::percent(1.0),
|
|
height: taffy::Dimension::auto(),
|
|
},
|
|
..Default::default()
|
|
};
|
|
// A float does not contribute to the height of a non-BFC block
|
|
// that contains it. Put an escaping float inside a zero-height,
|
|
// overflow-visible wrapper: its real width still reserves the
|
|
// current band, but its height can protrude into later descendant
|
|
// blocks of the ancestor BFC. A matching `clear` or any remaining
|
|
// direct full-width sibling keeps the old containing row, since
|
|
// that content explicitly terminates the local float zone.
|
|
let can_escape = zone_end == dom_children.len()
|
|
&& styles
|
|
.get(&parent_id)
|
|
.map(|style| !establishes_block_formatting_context(style))
|
|
.unwrap_or(false);
|
|
let row_float = if can_escape {
|
|
let wrapper_style = taffy::Style {
|
|
display: taffy::style::Display::Block,
|
|
flex_grow: 0.0,
|
|
flex_shrink: 0.0,
|
|
size: taffy::Size {
|
|
width: taffy::Dimension::auto(),
|
|
height: taffy::Dimension::length(0.0),
|
|
},
|
|
..Default::default()
|
|
};
|
|
taffy_tree
|
|
.new_with_children(wrapper_style, &[float_id])
|
|
.ok()
|
|
.unwrap_or(float_id)
|
|
} else {
|
|
float_id
|
|
};
|
|
let row_children: Vec<taffy::NodeId> = match float_side {
|
|
Some(crate::Float::Left) => [Some(row_float), flow_column].into_iter().flatten().collect(),
|
|
_ => [flow_column, Some(row_float)].into_iter().flatten().collect(),
|
|
};
|
|
if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) {
|
|
result.push(row);
|
|
if can_escape {
|
|
if let (Some(flow), Some(side)) = (flow_column, float_side) {
|
|
ifc_items.float_continuations.push(FloatContinuation {
|
|
owner: parent_id,
|
|
float: float_id,
|
|
flow,
|
|
side,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The float itself failed to build (e.g. display:none resolved for
|
|
// it specifically); still build its flow siblings so their content
|
|
// is not silently lost.
|
|
None => result.extend(
|
|
dom_children[float_idx + 1..zone_end]
|
|
.iter()
|
|
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)),
|
|
),
|
|
}
|
|
|
|
result.extend(
|
|
dom_children[zone_end..]
|
|
.iter()
|
|
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)),
|
|
);
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use obscura_dom::tree_sink::parse_html;
|
|
|
|
#[test]
|
|
fn lays_out_real_dom() {
|
|
let tree = parse_html(
|
|
"<html><body><div style=\"width: 300px\"><div style=\"width: 100px; height: 40px\"></div><div style=\"width: 100px; height: 60px\"></div></div></body></html>",
|
|
);
|
|
let laid = layout_dom(&tree, (1280.0, 720.0));
|
|
// Every element gets a rect.
|
|
assert!(laid.rects.len() >= 4, "expected >=4 element rects, got {}", laid.rects.len());
|
|
|
|
// The two inner divs stack vertically inside the 300px container.
|
|
let stacks = laid
|
|
.rects
|
|
.values()
|
|
.filter(|r| (r.width - 100.0).abs() < 0.1)
|
|
.map(|r| (r.y, r.height))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(stacks.len(), 2, "expected two 100px-wide children");
|
|
let mut sorted = stacks.clone();
|
|
sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
|
assert!(
|
|
(sorted[0].1 - 40.0).abs() < 0.1 && (sorted[1].1 - 60.0).abs() < 0.1,
|
|
"children heights should be 40 and 60, got {:?}",
|
|
sorted
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sticky_normal_flow_excludes_own_pixel_and_percentage_translates() {
|
|
let tree = parse_html(
|
|
r#"<style>
|
|
html,body{margin:0}
|
|
#flow{width:300px;height:900px}
|
|
.spacer{height:100px}
|
|
.sticky{position:sticky;top:0}
|
|
#pixel{width:120px;height:40px;transform:translate(7px,-15px)}
|
|
#percent{width:120px;height:50px;transform:translateY(-110%)}
|
|
</style>
|
|
<div id="flow">
|
|
<div class="spacer"></div>
|
|
<div id="pixel" class="sticky"></div>
|
|
<div class="spacer"></div>
|
|
<div id="percent" class="sticky"></div>
|
|
</div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
#ancestor{
|
|
width:300px;
|
|
height:700px;
|
|
transform:translate(25px,30px)
|
|
}
|
|
#spacer{height:100px}
|
|
#sticky{
|
|
position:sticky;
|
|
top:0;
|
|
width:120px;
|
|
height:40px;
|
|
transform:translate(-5px,-20px)
|
|
}
|
|
</style>
|
|
<div id="ancestor">
|
|
<div id="spacer"></div>
|
|
<div id="sticky"></div>
|
|
</div>"#,
|
|
);
|
|
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#"<html><head><style>
|
|
#container {
|
|
box-sizing:border-box;
|
|
container-type:inline-size;
|
|
width:500px;
|
|
padding:20px;
|
|
border:10px solid;
|
|
}
|
|
#target { width:1px; height:1px }
|
|
@container (width >= 440px) { #target { width:44px } }
|
|
@container (width > 499px) { #target { width:50px } }
|
|
</style></head><body>
|
|
<div id="container"><div id="target"></div></div>
|
|
</body></html>"#,
|
|
);
|
|
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#"<html><head><style>
|
|
#outer { container: shell / inline-size; width:600px }
|
|
#inner { container: other / inline-size; width:300px }
|
|
#target { width:1px; height:1px }
|
|
@container shell (min-width:500px) { #target { width:11px } }
|
|
@container (min-width:500px) { #target { height:22px } }
|
|
</style></head><body>
|
|
<div id="outer"><div id="inner"><div id="target"></div></div></div>
|
|
</body></html>"#,
|
|
);
|
|
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#"<html><head><style>
|
|
#outer { container: outer / inline-size; width:600px }
|
|
#inner { container: inner / inline-size; width:200px }
|
|
#target { width:1px }
|
|
@container outer (min-width:500px) {
|
|
@container inner (min-width:200px) {
|
|
#target { width:19px }
|
|
}
|
|
}
|
|
</style></head><body>
|
|
<div id="outer"><div id="inner"><div id="target"></div></div></div>
|
|
</body></html>"#,
|
|
);
|
|
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#"<html><head><style>
|
|
#container { container-type:inline-size; width:300px }
|
|
@container (min-width:300px) {
|
|
#container::before { content:"active"; display:block }
|
|
}
|
|
</style></head><body><div id="container"></div></body></html>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
#host{width:600px}
|
|
.container{container-type:inline-size}
|
|
.wide{width:240px;height:30px}
|
|
#inline-block{display:inline-block}
|
|
#inline-flex{display:inline-flex}
|
|
#inline-grid{display:inline-grid}
|
|
</style>
|
|
<div id="host">
|
|
<div id="inline-block" class="container"><div class="wide"></div></div>
|
|
<div id="inline-flex" class="container"><div class="wide"></div></div>
|
|
<div id="inline-grid" class="container"><div class="wide"></div></div>
|
|
</div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.host{width:700px}
|
|
.wide{width:400px;height:20px}
|
|
#ib,#ibt{display:inline-block}
|
|
#if,#ift{display:inline-flex}
|
|
#ig,#igt{display:inline-grid}
|
|
</style>
|
|
<div class="host"><div id="ib"><div class="wide"></div></div></div>
|
|
<div class="host"><div id="if"><div class="wide"></div></div></div>
|
|
<div class="host"><div id="ig"><div class="wide"></div></div></div>
|
|
<div class="host">A<div id="ibt"><div class="wide"></div></div>B</div>
|
|
<div class="host">A<div id="ift"><div class="wide"></div></div>B</div>
|
|
<div class="host">A<div id="igt"><div class="wide"></div></div>B</div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.host{width:700px}
|
|
.percent{width:50%}
|
|
.flex{display:inline-flex}
|
|
.grid{display:inline-grid}
|
|
.edges{padding:10px 20px;border:5px solid;margin:7px}
|
|
.child{width:100px;height:20px}
|
|
</style>
|
|
<div class="host"><div id="flex-percent" class="flex percent"><div class="child"></div></div></div>
|
|
<div class="host"><div id="grid-percent" class="grid percent"><div class="child"></div></div></div>
|
|
<div class="host"><div id="flex-edges" class="flex edges"><div class="child"></div></div></div>
|
|
<div class="host"><div id="grid-edges" class="grid edges"><div class="child"></div></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.host{width:700px}
|
|
.flex{display:inline-flex}
|
|
.grid{display:inline-grid}
|
|
.max{max-width:50%}
|
|
.min{min-width:50%}
|
|
.wide{width:500px;height:20px}
|
|
.narrow{width:100px;height:20px}
|
|
</style>
|
|
<div class="host"><div id="flex-max" class="flex max"><div class="wide"></div></div></div>
|
|
<div class="host"><div id="grid-max" class="grid max"><div class="wide"></div></div></div>
|
|
<div class="host"><div id="flex-min" class="flex min"><div class="narrow"></div></div></div>
|
|
<div class="host"><div id="grid-min" class="grid min"><div class="narrow"></div></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.line{font:20px/40px sans-serif}
|
|
.atom{display:inline-flex;width:30px}
|
|
.bottom{vertical-align:bottom}
|
|
.middle{vertical-align:middle}
|
|
#bottom-short,#middle-short{height:10px}
|
|
#bottom-tall,#middle-tall{height:30px}
|
|
</style>
|
|
<div class="line">x<span id="bottom-short" class="atom bottom"></span><span id="bottom-tall" class="atom bottom"></span></div>
|
|
<div class="line">x<span id="middle-short" class="atom middle"></span><span id="middle-tall" class="atom middle"></span></div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.host{display:flex;width:700px}
|
|
#if{display:inline-flex}
|
|
#ig{display:inline-grid;grid-template-columns:100px 100px}
|
|
.item{width:100px;height:20px}
|
|
</style>
|
|
<div class="host"><div id="if"><div id="ifa" class="item"></div><div id="ifb" class="item"></div></div></div>
|
|
<div class="host"><div id="ig"><div id="iga" class="item"></div><div id="igb" class="item"></div></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html{{margin:0}}body{{margin:0;display:{display}}}
|
|
.wide{{width:400px;height:20px}}
|
|
</style><div class="wide"></div>"#
|
|
));
|
|
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#"<style>
|
|
html{{margin:0;display:{display}}}body{{margin:0}}
|
|
.wide{{width:400px;height:20px}}
|
|
</style><div class="wide"></div>"#
|
|
));
|
|
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#"<style>
|
|
html,body{margin:0;height:100%;overflow:hidden}
|
|
main{padding-top:48px}
|
|
#long{height:5000px}
|
|
</style><main id="main"><div id="long"></div></main>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
#clip{height:100px;overflow:hidden}
|
|
#long{height:5000px}
|
|
</style><div id="clip"><div id="long"></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
.container{width:200px}
|
|
.child{height:80px}
|
|
#inline-only{container-type:inline-size}
|
|
#both{container-type:size}
|
|
</style>
|
|
<div id="inline-only" class="container"><div class="child"></div></div>
|
|
<div id="both" class="container"><div class="child"></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
#container{container-type:inline-size;width:200px}
|
|
#float{float:left;width:50px;height:40px}
|
|
</style>
|
|
<div id="container"><div id="float"></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
#outer{container-type:inline-size;width:500px}
|
|
#inner{display:inline;container-type:inline-size}
|
|
#target{width:1px}
|
|
@container (min-width:400px){#target{width:99px}}
|
|
</style>
|
|
<div id="outer"><span id="inner"><span id="target"></span></span></div>"#,
|
|
);
|
|
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#"<style>
|
|
#outer{container:query/inline-size;width:500px}
|
|
#inner{display:contents;container:query/inline-size}
|
|
#target{width:1px}
|
|
@container query (min-width:400px){#target{width:99px}}
|
|
</style>
|
|
<div id="outer"><div id="inner"><div id="target"></div></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
#outer{container-type:inline-size;width:500px}
|
|
#inner{display:table;container-type:size;width:300px}
|
|
#content{height:80px}
|
|
#target{width:1px}
|
|
@container (min-width:400px){#target{width:99px}}
|
|
</style>
|
|
<div id="outer">
|
|
<div id="inner"><div id="content"><div id="target"></div></div></div>
|
|
</div>"#,
|
|
);
|
|
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#"<style>
|
|
html,body{margin:0}
|
|
table{border-spacing:0}
|
|
#cell{container-type:size;padding:0}
|
|
#content{height:80px;width:120px}
|
|
</style>
|
|
<table><tr><td id="cell"><div id="content"></div></td></tr></table>"#,
|
|
);
|
|
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#"<style>
|
|
#container{display:inline-block;container-type:inline-size}
|
|
#child{width:200px;height:10px}
|
|
@container (min-width:100px){#child{width:50px}}
|
|
</style>
|
|
<div id="container"><div id="child"></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
#c0{container:c0/inline-size;width:100px}
|
|
#target{width:1px}
|
|
@container c0 (min-width:100px){
|
|
#c1{container:c1/inline-size;width:100px}
|
|
}
|
|
@container c1 (min-width:100px){#target{width:99px}}
|
|
</style>
|
|
<div id="c0"><div id="c1"><div id="target"></div></div></div>"#,
|
|
);
|
|
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!("<div id=\"c{level}\">"));
|
|
}
|
|
body.push_str("<div id=\"target\"></div>");
|
|
for _ in 0..=10 {
|
|
body.push_str("</div>");
|
|
}
|
|
let tree = parse_html(&format!("<style>{css}</style>{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#"<html><head><style>#target{width:25px}</style></head>
|
|
<body><div id="target"></div></body></html>"#,
|
|
);
|
|
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#"<style>
|
|
html, body { margin:0 }
|
|
.button { width:123px; height:20px }
|
|
</style>
|
|
<x-button>
|
|
<template shadowrootmode="open">
|
|
<style>.button { width:100% }</style>
|
|
<span class="button">shadow control</span>
|
|
</template>
|
|
</x-button>
|
|
<div id="light-button" class="button"></div>"#,
|
|
);
|
|
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(
|
|
"<html><head><style>#columns{columns:1}@media (width >= 700px){#columns{columns:3}}</style></head>\
|
|
<body style=\"margin:0\"><div id=\"columns\" style=\"column-gap:30px;width:630px\">\
|
|
<div id=\"a\" style=\"height:180px;break-inside:avoid\"></div>\
|
|
<div id=\"b\" style=\"height:120px;break-inside:avoid\"></div>\
|
|
<div id=\"c\" style=\"height:60px;break-inside:avoid\"></div>\
|
|
<div id=\"d\" style=\"height:150px;break-inside:avoid\"></div>\
|
|
<div id=\"e\" style=\"height:90px;break-inside:avoid\"></div>\
|
|
<div id=\"f\" style=\"height:120px;break-inside:avoid\"></div>\
|
|
<div id=\"g\" style=\"height:80px;break-inside:avoid\"></div>\
|
|
<div id=\"h\" style=\"height:100px;break-inside:avoid\"></div>\
|
|
</div></body></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 multicol_does_not_atomize_breakable_prose_boxes() {
|
|
let tree = parse_html(
|
|
"<html><body style=\"margin:0\"><main id=\"columns\" style=\"columns:2;width:200px\">\
|
|
<p id=\"first\" style=\"height:100px;margin:0\"></p>\
|
|
<p id=\"second\" style=\"height:100px;margin:0\"></p>\
|
|
</main></body></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(
|
|
"<style>\
|
|
*{box-sizing:border-box}body{margin:0}\
|
|
#quote{display:flex;gap:8px;width:300px;font:16px/20px 'Liberation Sans'}\
|
|
#quote::before{content:'';display:block;width:16px;height:16px;flex-shrink:0}\
|
|
</style>\
|
|
<div id=\"quote\">This anonymous text item must wrap inside the remaining flex space instead of overflowing.</div>",
|
|
);
|
|
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#"<style>
|
|
* { box-sizing: border-box }
|
|
body { margin: 0; font: 16px/24px "Liberation Sans" }
|
|
#row { display: flex; gap: 32px; width: 768px }
|
|
#column {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
#quote { display: flex; gap: 8px; margin: 0 }
|
|
#quote::before {
|
|
content: "";
|
|
display: block;
|
|
flex-shrink: 0;
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
#logo { width: 203px; height: 128px; flex-shrink: 0 }
|
|
</style>
|
|
<section id="row">
|
|
<div id="column"><blockquote id="quote">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.</blockquote></div>
|
|
<div id="logo"></div>
|
|
</section>"#,
|
|
);
|
|
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#"<style>
|
|
* {{ box-sizing: border-box }}
|
|
body {{ margin: 0; font: 16px/24px "Liberation Sans" }}
|
|
#row {{ display: flex; gap: 32px; width: 768px }}
|
|
#column {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}}
|
|
#quote {{ display: flex; margin: 0 }}
|
|
#logo {{ width: 203px; height: 128px; flex-shrink: 0 }}
|
|
</style>
|
|
<section id="row">
|
|
<div id="column"><blockquote id="quote">{word}</blockquote></div>
|
|
<div id="logo"></div>
|
|
</section>"#
|
|
));
|
|
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#"<style>
|
|
#sheet { width: 320px !important; background: green !important }
|
|
.case#sheet { width: 80px; background: red }
|
|
.inline-normal { width: 320px !important; background: green !important }
|
|
#inline-important { width: 80px !important; background: red !important }
|
|
.custom { --w: 320px !important; --w: 80px; width: var(--w) }
|
|
.hint { background: green }
|
|
</style>
|
|
<div id="sheet" class="case"></div>
|
|
<div id="inline-normal" class="inline-normal" style="width:80px;background:red"></div>
|
|
<div id="inline-important" style="width:320px!important;background:green!important"></div>
|
|
<div id="inline-order" style="width:320px!important;width:80px;background:green!important;background:red"></div>
|
|
<div id="custom" class="custom"></div>
|
|
<div id="hint" class="hint" bgcolor="red"></div>"#,
|
|
);
|
|
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#"<style>
|
|
body { margin: 0 }
|
|
.box { width:100px; height:50px; padding:10px; border:2px solid black }
|
|
.border { box-sizing:border-box }
|
|
.parent { width:400px }
|
|
.half { width:50%; padding:10px; border:2px solid black }
|
|
.limited { width:200px; max-width:100px; padding:10px; border:2px solid black }
|
|
.inherit-parent { box-sizing:border-box }
|
|
.inherit-child { box-sizing:inherit; width:100px; padding:10px; border:2px solid black }
|
|
</style>
|
|
<div id="content" class="box"></div>
|
|
<div id="border" class="box border"></div>
|
|
<div class="parent"><div id="half-content" class="half"></div><div id="half-border" class="half border"></div></div>
|
|
<div id="max-content" class="limited"></div>
|
|
<div id="max-border" class="limited border"></div>
|
|
<div class="inherit-parent"><div id="inherited-border" class="inherit-child"></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
html { font-size: 62.5% }
|
|
body { margin: 0 }
|
|
#box {
|
|
display: grid;
|
|
width: 4rem;
|
|
margin-top: 3rem;
|
|
row-gap: 4rem;
|
|
column-gap: calc(1rem + 2vw);
|
|
}
|
|
#box > div { height: 2rem }
|
|
</style>
|
|
<div id="box"><div></div><div></div></div>"#,
|
|
);
|
|
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#"<style>
|
|
body { margin: 0 }
|
|
.a { position: relative; height: 10px; margin-bottom: 30px }
|
|
.b { position: relative; display: grid; height: 10px; margin-top: 40px }
|
|
</style>
|
|
<div class="a"></div>
|
|
<div id="b" class="b"></div>"#,
|
|
);
|
|
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#"<div id="cta" style="display:flex;text-transform:uppercase">get started</div>"#,
|
|
);
|
|
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#"<div id="code" style="display:flex;font:16px/32px monospace">code</div>"#,
|
|
);
|
|
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#"<html class="light"><head><style>
|
|
.hero::before {
|
|
content:"";
|
|
position:absolute;
|
|
background:radial-gradient(circle, #ebf3f9, #d6dee4);
|
|
}
|
|
@media (prefers-color-scheme:dark) {
|
|
:root:not(.light) .hero::before {
|
|
background:radial-gradient(circle, #111111, #222222);
|
|
}
|
|
}
|
|
:root.dark .hero::before {
|
|
background:radial-gradient(circle, #333333, #444444);
|
|
}
|
|
</style></head><body><div class="hero"></div></body></html>"#,
|
|
);
|
|
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#"<style>
|
|
table { border-spacing:0 }
|
|
td { width:100px; padding:10px; border:2px solid black }
|
|
.border { box-sizing:border-box }
|
|
</style>
|
|
<table><tr><td id="content-cell">content</td></tr></table>
|
|
<table><tr><td id="border-cell" class="border">border</td></tr></table>"#,
|
|
);
|
|
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#"<style>
|
|
html { font-size:16px }
|
|
button { font-size:.875rem; line-height:1.5 }
|
|
button::after {
|
|
content:attr(text); position:absolute; inset:1px;
|
|
}
|
|
</style>
|
|
<button id="cta" text="Get Started">Get Started</button>"#,
|
|
);
|
|
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#"<style>
|
|
html, body { margin:0 }
|
|
#host {
|
|
position:absolute; width:max-content;
|
|
padding:12px 24px; border:0;
|
|
font-size:14px; font-weight:600; color:transparent;
|
|
}
|
|
#host::after {
|
|
content:attr(text); position:absolute; inset:1px;
|
|
display:flex; align-items:center; justify-content:center;
|
|
color:black; background:white;
|
|
}
|
|
</style>
|
|
<button id="host" text="Learn more">Learn more</button>"#,
|
|
);
|
|
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#"<style>
|
|
#parent {
|
|
font-optical-sizing:none;
|
|
font-variation-settings:"opsz" 20, "wght" 650;
|
|
}
|
|
#parent::before { content:"before" }
|
|
#reset {
|
|
font-optical-sizing:initial;
|
|
font-variation-settings:normal;
|
|
}
|
|
#reset::after {
|
|
content:"after";
|
|
font-optical-sizing:inherit;
|
|
font-variation-settings:"wght" 300;
|
|
}
|
|
</style>
|
|
<div id="parent">
|
|
<span id="inherited"></span>
|
|
<span id="reset"><b id="reset-child"></b></span>
|
|
</div>"#,
|
|
);
|
|
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#"<select id="language">
|
|
<option selected>En</option>
|
|
<option>Brazilian Portuguese</option>
|
|
</select>"#,
|
|
);
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
}
|