we (web engine): Experimental web browser project to understand the limits of Claude
1//! Block layout engine: box generation, block/inline layout, and text wrapping.
2//!
3//! Builds a layout tree from a styled tree (DOM + computed styles) and positions
4//! block-level elements vertically with proper inline formatting context.
5
6use std::collections::HashMap;
7
8use we_css::values::Color;
9use we_dom::{Document, NodeData, NodeId};
10use we_style::computed::{
11 AlignContent, AlignItems, AlignSelf, BorderStyle, BoxSizing, Clear, ComputedStyle, Display,
12 FlexDirection, FlexWrap, Float, JustifyContent, LengthOrAuto, Overflow, Position, StyledNode,
13 TextAlign, TextDecoration, Visibility,
14};
15use we_text::font::Font;
16
17/// Width of scroll bars in pixels.
18pub const SCROLLBAR_WIDTH: f32 = 15.0;
19
20/// Edge sizes for box model (margin, padding, border).
21#[derive(Debug, Clone, Copy, Default, PartialEq)]
22pub struct EdgeSizes {
23 pub top: f32,
24 pub right: f32,
25 pub bottom: f32,
26 pub left: f32,
27}
28
29/// A positioned rectangle with content area dimensions.
30#[derive(Debug, Clone, Copy, Default, PartialEq)]
31pub struct Rect {
32 pub x: f32,
33 pub y: f32,
34 pub width: f32,
35 pub height: f32,
36}
37
38/// The type of layout box.
39#[derive(Debug)]
40pub enum BoxType {
41 /// Block-level box from an element.
42 Block(NodeId),
43 /// Inline-level box from an element.
44 Inline(NodeId),
45 /// A run of text from a text node.
46 TextRun { node: NodeId, text: String },
47 /// Anonymous block wrapping inline content within a block container.
48 Anonymous,
49}
50
51/// A single positioned text fragment with its own styling.
52///
53/// Multiple fragments can share the same y-coordinate when they are
54/// on the same visual line (e.g. `<p>Hello <em>world</em></p>` produces
55/// two fragments at the same y).
56#[derive(Debug, Clone, PartialEq)]
57pub struct TextLine {
58 pub text: String,
59 pub x: f32,
60 pub y: f32,
61 pub width: f32,
62 pub font_size: f32,
63 pub color: Color,
64 pub text_decoration: TextDecoration,
65 pub background_color: Color,
66}
67
68/// A box in the layout tree with dimensions and child boxes.
69#[derive(Debug)]
70pub struct LayoutBox {
71 pub box_type: BoxType,
72 pub display: Display,
73 pub rect: Rect,
74 pub margin: EdgeSizes,
75 pub padding: EdgeSizes,
76 pub border: EdgeSizes,
77 pub children: Vec<LayoutBox>,
78 pub font_size: f32,
79 /// Positioned text fragments (populated for boxes with inline content).
80 pub lines: Vec<TextLine>,
81 /// Text color.
82 pub color: Color,
83 /// Background color.
84 pub background_color: Color,
85 /// Text decoration (underline, etc.).
86 pub text_decoration: TextDecoration,
87 /// Border styles (top, right, bottom, left).
88 pub border_styles: [BorderStyle; 4],
89 /// Border colors (top, right, bottom, left).
90 pub border_colors: [Color; 4],
91 /// Text alignment for this box's inline content.
92 pub text_align: TextAlign,
93 /// Computed line height in px.
94 pub line_height: f32,
95 /// For replaced elements (e.g., `<img>`): content dimensions (width, height).
96 pub replaced_size: Option<(f32, f32)>,
97 /// CSS `position` property.
98 pub position: Position,
99 /// CSS `z-index` property (None = auto).
100 pub z_index: Option<i32>,
101 /// Relative position offset (dx, dy) applied after normal flow layout.
102 pub relative_offset: (f32, f32),
103 /// CSS `overflow` property.
104 pub overflow: Overflow,
105 /// CSS `box-sizing` property.
106 pub box_sizing: BoxSizing,
107 /// CSS `width` property (explicit or auto, may contain percentage).
108 pub css_width: LengthOrAuto,
109 /// CSS `height` property (explicit or auto, may contain percentage).
110 pub css_height: LengthOrAuto,
111 /// CSS margin values (may contain percentages for layout resolution).
112 pub css_margin: [LengthOrAuto; 4],
113 /// CSS padding values (may contain percentages for layout resolution).
114 pub css_padding: [LengthOrAuto; 4],
115 /// CSS position offset values (top, right, bottom, left) for relative/sticky positioning.
116 pub css_offsets: [LengthOrAuto; 4],
117 /// For `position: sticky`: the containing block's content rect in document
118 /// coordinates. Paint-time logic clamps the element within this rectangle.
119 pub sticky_constraint: Option<Rect>,
120 /// CSS `visibility` property.
121 pub visibility: Visibility,
122 /// CSS `float` property.
123 pub float: Float,
124 /// CSS `clear` property.
125 pub clear: Clear,
126 /// Natural content height before CSS height override.
127 /// Used to determine overflow for scroll containers.
128 pub content_height: f32,
129 // Flex container properties
130 pub flex_direction: FlexDirection,
131 pub flex_wrap: FlexWrap,
132 pub justify_content: JustifyContent,
133 pub align_items: AlignItems,
134 pub align_content: AlignContent,
135 pub row_gap: f32,
136 pub column_gap: f32,
137 // Flex item properties
138 pub flex_grow: f32,
139 pub flex_shrink: f32,
140 pub flex_basis: LengthOrAuto,
141 pub align_self: AlignSelf,
142 pub order: i32,
143}
144
145impl LayoutBox {
146 fn new(box_type: BoxType, style: &ComputedStyle) -> Self {
147 LayoutBox {
148 box_type,
149 display: style.display,
150 rect: Rect::default(),
151 margin: EdgeSizes::default(),
152 padding: EdgeSizes::default(),
153 border: EdgeSizes::default(),
154 children: Vec::new(),
155 font_size: style.font_size,
156 lines: Vec::new(),
157 color: style.color,
158 background_color: style.background_color,
159 text_decoration: style.text_decoration,
160 border_styles: [
161 style.border_top_style,
162 style.border_right_style,
163 style.border_bottom_style,
164 style.border_left_style,
165 ],
166 border_colors: [
167 style.border_top_color,
168 style.border_right_color,
169 style.border_bottom_color,
170 style.border_left_color,
171 ],
172 text_align: style.text_align,
173 line_height: style.line_height,
174 replaced_size: None,
175 position: style.position,
176 z_index: style.z_index,
177 relative_offset: (0.0, 0.0),
178 overflow: style.overflow,
179 box_sizing: style.box_sizing,
180 css_width: style.width,
181 css_height: style.height,
182 css_margin: [
183 style.margin_top,
184 style.margin_right,
185 style.margin_bottom,
186 style.margin_left,
187 ],
188 css_padding: [
189 style.padding_top,
190 style.padding_right,
191 style.padding_bottom,
192 style.padding_left,
193 ],
194 css_offsets: [style.top, style.right, style.bottom, style.left],
195 sticky_constraint: None,
196 visibility: style.visibility,
197 float: style.float,
198 clear: style.clear,
199 content_height: 0.0,
200 flex_direction: style.flex_direction,
201 flex_wrap: style.flex_wrap,
202 justify_content: style.justify_content,
203 align_items: style.align_items,
204 align_content: style.align_content,
205 row_gap: style.row_gap,
206 column_gap: style.column_gap,
207 flex_grow: style.flex_grow,
208 flex_shrink: style.flex_shrink,
209 flex_basis: style.flex_basis,
210 align_self: style.align_self,
211 order: style.order,
212 }
213 }
214
215 /// Total height including margin, border, and padding.
216 pub fn margin_box_height(&self) -> f32 {
217 self.margin.top
218 + self.border.top
219 + self.padding.top
220 + self.rect.height
221 + self.padding.bottom
222 + self.border.bottom
223 + self.margin.bottom
224 }
225
226 /// Iterate over all boxes in depth-first pre-order.
227 pub fn iter(&self) -> LayoutBoxIter<'_> {
228 LayoutBoxIter { stack: vec![self] }
229 }
230}
231
232/// Depth-first pre-order iterator over layout boxes.
233pub struct LayoutBoxIter<'a> {
234 stack: Vec<&'a LayoutBox>,
235}
236
237impl<'a> Iterator for LayoutBoxIter<'a> {
238 type Item = &'a LayoutBox;
239
240 fn next(&mut self) -> Option<&'a LayoutBox> {
241 let node = self.stack.pop()?;
242 for child in node.children.iter().rev() {
243 self.stack.push(child);
244 }
245 Some(node)
246 }
247}
248
249/// The result of laying out a document.
250#[derive(Debug)]
251pub struct LayoutTree {
252 pub root: LayoutBox,
253 pub width: f32,
254 pub height: f32,
255}
256
257impl LayoutTree {
258 /// Iterate over all layout boxes in depth-first pre-order.
259 pub fn iter(&self) -> LayoutBoxIter<'_> {
260 self.root.iter()
261 }
262}
263
264// ---------------------------------------------------------------------------
265// Resolve LengthOrAuto to f32
266// ---------------------------------------------------------------------------
267
268/// Resolve a `LengthOrAuto` to px. Percentages are resolved against
269/// `reference` (typically the containing block width). Auto resolves to 0.
270fn resolve_length_against(value: LengthOrAuto, reference: f32) -> f32 {
271 match value {
272 LengthOrAuto::Length(px) => px,
273 LengthOrAuto::Percentage(p) => p / 100.0 * reference,
274 LengthOrAuto::Auto => 0.0,
275 }
276}
277
278/// Resolve horizontal offset for `position: relative`.
279/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr).
280fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto, cb_width: f32) -> f32 {
281 match left {
282 LengthOrAuto::Length(px) => px,
283 LengthOrAuto::Percentage(p) => p / 100.0 * cb_width,
284 LengthOrAuto::Auto => match right {
285 LengthOrAuto::Length(px) => -px,
286 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_width),
287 LengthOrAuto::Auto => 0.0,
288 },
289 }
290}
291
292/// Resolve vertical offset for `position: relative`.
293/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3).
294fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto, cb_height: f32) -> f32 {
295 match top {
296 LengthOrAuto::Length(px) => px,
297 LengthOrAuto::Percentage(p) => p / 100.0 * cb_height,
298 LengthOrAuto::Auto => match bottom {
299 LengthOrAuto::Length(px) => -px,
300 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_height),
301 LengthOrAuto::Auto => 0.0,
302 },
303 }
304}
305
306// ---------------------------------------------------------------------------
307// Build layout tree from styled tree
308// ---------------------------------------------------------------------------
309
310fn build_box(
311 styled: &StyledNode,
312 doc: &Document,
313 image_sizes: &HashMap<NodeId, (f32, f32)>,
314) -> Option<LayoutBox> {
315 let node = styled.node;
316 let style = &styled.style;
317
318 match doc.node_data(node) {
319 NodeData::Document => {
320 let mut children = Vec::new();
321 for child in &styled.children {
322 if let Some(child_box) = build_box(child, doc, image_sizes) {
323 children.push(child_box);
324 }
325 }
326 if children.len() == 1 {
327 children.into_iter().next()
328 } else if children.is_empty() {
329 None
330 } else {
331 let mut b = LayoutBox::new(BoxType::Anonymous, style);
332 b.children = children;
333 Some(b)
334 }
335 }
336 NodeData::Element { .. } => {
337 if style.display == Display::None {
338 return None;
339 }
340
341 // Margin and padding: resolve absolute lengths now; percentages
342 // will be re-resolved in compute_layout against containing block.
343 // Use 0.0 as a placeholder reference for percentages — they'll be
344 // resolved properly in compute_layout.
345 let margin = EdgeSizes {
346 top: resolve_length_against(style.margin_top, 0.0),
347 right: resolve_length_against(style.margin_right, 0.0),
348 bottom: resolve_length_against(style.margin_bottom, 0.0),
349 left: resolve_length_against(style.margin_left, 0.0),
350 };
351 let padding = EdgeSizes {
352 top: resolve_length_against(style.padding_top, 0.0),
353 right: resolve_length_against(style.padding_right, 0.0),
354 bottom: resolve_length_against(style.padding_bottom, 0.0),
355 left: resolve_length_against(style.padding_left, 0.0),
356 };
357 let border = EdgeSizes {
358 top: if style.border_top_style != BorderStyle::None {
359 style.border_top_width
360 } else {
361 0.0
362 },
363 right: if style.border_right_style != BorderStyle::None {
364 style.border_right_width
365 } else {
366 0.0
367 },
368 bottom: if style.border_bottom_style != BorderStyle::None {
369 style.border_bottom_width
370 } else {
371 0.0
372 },
373 left: if style.border_left_style != BorderStyle::None {
374 style.border_left_width
375 } else {
376 0.0
377 },
378 };
379
380 let mut children = Vec::new();
381 for child in &styled.children {
382 if let Some(child_box) = build_box(child, doc, image_sizes) {
383 children.push(child_box);
384 }
385 }
386
387 // Per CSS2 §9.7: float forces display to block.
388 let effective_display =
389 if style.float != Float::None && style.display == Display::Inline {
390 Display::Block
391 } else {
392 style.display
393 };
394
395 let box_type = match effective_display {
396 Display::Block | Display::Flex | Display::InlineFlex => BoxType::Block(node),
397 Display::Inline => BoxType::Inline(node),
398 Display::None => unreachable!(),
399 };
400
401 if effective_display == Display::Block {
402 children = normalize_children(children, style);
403 }
404
405 let mut b = LayoutBox::new(box_type, style);
406 b.margin = margin;
407 b.padding = padding;
408 b.border = border;
409 b.children = children;
410
411 // Check for replaced element (e.g., <img>).
412 if let Some(&(w, h)) = image_sizes.get(&node) {
413 b.replaced_size = Some((w, h));
414 }
415
416 // Relative position offsets are resolved in compute_layout
417 // where the containing block dimensions are known.
418
419 Some(b)
420 }
421 NodeData::Text { data } => {
422 let collapsed = collapse_whitespace(data);
423 if collapsed.is_empty() {
424 return None;
425 }
426 Some(LayoutBox::new(
427 BoxType::TextRun {
428 node,
429 text: collapsed,
430 },
431 style,
432 ))
433 }
434 NodeData::Comment { .. } => None,
435 }
436}
437
438/// Collapse runs of whitespace to a single space.
439fn collapse_whitespace(s: &str) -> String {
440 let mut result = String::new();
441 let mut in_ws = false;
442 for ch in s.chars() {
443 if ch.is_whitespace() {
444 if !in_ws {
445 result.push(' ');
446 }
447 in_ws = true;
448 } else {
449 in_ws = false;
450 result.push(ch);
451 }
452 }
453 result
454}
455
456/// If a block container has a mix of block-level and inline-level children,
457/// wrap consecutive inline runs in anonymous block boxes.
458/// Out-of-flow elements (absolute/fixed) are excluded from the determination
459/// and placed at the top level without affecting normalization.
460fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> {
461 if children.is_empty() {
462 return children;
463 }
464
465 // Consider in-flow block children and floated children as "block-level"
466 // for normalization purposes. When floats are present alongside inline
467 // content, the inline content must be wrapped in anonymous blocks so that
468 // `layout_block_children` can lay it out via its inline formatting context.
469 let has_block = children
470 .iter()
471 .any(|c| (is_in_flow(c) && is_block_level(c)) || is_floated(c));
472 if !has_block {
473 return children;
474 }
475
476 let has_inline = children.iter().any(|c| is_in_flow(c) && !is_block_level(c));
477 if !has_inline {
478 return children;
479 }
480
481 let mut result = Vec::new();
482 let mut inline_group: Vec<LayoutBox> = Vec::new();
483
484 for child in children {
485 if !is_in_flow(&child) || is_block_level(&child) {
486 // Out-of-flow or block-level: flush any pending inline group first.
487 if !inline_group.is_empty() {
488 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style);
489 anon.children = std::mem::take(&mut inline_group);
490 result.push(anon);
491 }
492 result.push(child);
493 } else {
494 inline_group.push(child);
495 }
496 }
497
498 if !inline_group.is_empty() {
499 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style);
500 anon.children = inline_group;
501 result.push(anon);
502 }
503
504 result
505}
506
507fn is_block_level(b: &LayoutBox) -> bool {
508 matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous)
509}
510
511/// Returns `true` if this box is in normal flow (not absolutely/fixed positioned, not floated).
512fn is_in_flow(b: &LayoutBox) -> bool {
513 b.position != Position::Absolute && b.position != Position::Fixed && b.float == Float::None
514}
515
516/// Returns `true` if this box is floated.
517fn is_floated(b: &LayoutBox) -> bool {
518 b.float != Float::None
519}
520
521// ---------------------------------------------------------------------------
522// Float tracking
523// ---------------------------------------------------------------------------
524
525/// A positioned float rectangle used for tracking placed floats.
526#[derive(Debug, Clone, Copy)]
527struct PlacedFloat {
528 /// Left edge of the float's margin box.
529 x: f32,
530 /// Top edge of the float's margin box.
531 y: f32,
532 /// Width of the float's margin box.
533 width: f32,
534 /// Height of the float's margin box.
535 height: f32,
536 /// Which side this float is on.
537 side: Float,
538}
539
540/// Tracks active floats within a block formatting context.
541#[derive(Debug, Default)]
542struct FloatContext {
543 floats: Vec<PlacedFloat>,
544}
545
546impl FloatContext {
547 /// Get the available x-range at a given y position, narrowed by active floats.
548 /// Returns (left_edge, right_edge) within the containing block.
549 fn available_range(
550 &self,
551 y: f32,
552 line_height: f32,
553 container_x: f32,
554 container_width: f32,
555 ) -> (f32, f32) {
556 let mut left = container_x;
557 let mut right = container_x + container_width;
558
559 for f in &self.floats {
560 let float_top = f.y;
561 let float_bottom = f.y + f.height;
562
563 // Check if this float overlaps vertically with the line.
564 if y + line_height > float_top && y < float_bottom {
565 match f.side {
566 Float::Left => {
567 let float_right_edge = f.x + f.width;
568 if float_right_edge > left {
569 left = float_right_edge;
570 }
571 }
572 Float::Right => {
573 let float_left_edge = f.x;
574 if float_left_edge < right {
575 right = float_left_edge;
576 }
577 }
578 Float::None => {}
579 }
580 }
581 }
582
583 (left, right)
584 }
585
586 /// Find the y position below all floats that match the given clear side.
587 fn clear_y(&self, clear: Clear) -> f32 {
588 let mut y = 0.0f32;
589 for f in &self.floats {
590 let dominated = match clear {
591 Clear::Left => f.side == Float::Left,
592 Clear::Right => f.side == Float::Right,
593 Clear::Both => true,
594 Clear::None => false,
595 };
596 if dominated {
597 let bottom = f.y + f.height;
598 if bottom > y {
599 y = bottom;
600 }
601 }
602 }
603 y
604 }
605
606 /// Find the bottom edge of all placed floats.
607 fn max_float_bottom(&self) -> f32 {
608 let mut bottom = 0.0f32;
609 for f in &self.floats {
610 let fb = f.y + f.height;
611 if fb > bottom {
612 bottom = fb;
613 }
614 }
615 bottom
616 }
617
618 /// Place a float and return its position.
619 fn place_float(
620 &mut self,
621 float_side: Float,
622 float_width: f32,
623 float_height: f32,
624 cursor_y: f32,
625 container_x: f32,
626 container_width: f32,
627 ) -> (f32, f32) {
628 // Start at cursor_y and find a position where the float fits.
629 let mut y = cursor_y;
630
631 loop {
632 let (left, right) = self.available_range(y, float_height, container_x, container_width);
633 let available = right - left;
634
635 if available >= float_width || available >= container_width {
636 let x = match float_side {
637 Float::Left => left,
638 Float::Right => right - float_width,
639 Float::None => unreachable!(),
640 };
641 self.floats.push(PlacedFloat {
642 x,
643 y,
644 width: float_width,
645 height: float_height,
646 side: float_side,
647 });
648 return (x, y);
649 }
650
651 // Move down below the topmost interfering float and try again.
652 let mut next_y = f32::MAX;
653 for f in &self.floats {
654 let fb = f.y + f.height;
655 if fb > y && fb < next_y {
656 next_y = fb;
657 }
658 }
659 if next_y == f32::MAX {
660 // No more floats to clear, place at current position.
661 let x = match float_side {
662 Float::Left => container_x,
663 Float::Right => container_x + container_width - float_width,
664 Float::None => unreachable!(),
665 };
666 self.floats.push(PlacedFloat {
667 x,
668 y,
669 width: float_width,
670 height: float_height,
671 side: float_side,
672 });
673 return (x, y);
674 }
675 y = next_y;
676 }
677 }
678}
679
680// ---------------------------------------------------------------------------
681// Layout algorithm
682// ---------------------------------------------------------------------------
683
684/// Position and size a layout box within `available_width` at position (`x`, `y`).
685///
686/// `available_width` is the containing block width — used as the reference for
687/// percentage widths, margins, and paddings (per CSS spec, even vertical margins/
688/// padding resolve against the containing block width).
689///
690/// `abs_cb` is the padding box of the nearest positioned ancestor, used as the
691/// containing block for absolutely positioned descendants.
692#[allow(clippy::too_many_arguments)]
693fn compute_layout(
694 b: &mut LayoutBox,
695 x: f32,
696 y: f32,
697 available_width: f32,
698 viewport_width: f32,
699 viewport_height: f32,
700 font: &Font,
701 doc: &Document,
702 abs_cb: Rect,
703 float_ctx: Option<&FloatContext>,
704) {
705 // Resolve percentage margins against containing block width.
706 // Only re-resolve percentages — absolute margins may have been modified
707 // by margin collapsing and must not be overwritten.
708 if matches!(b.css_margin[0], LengthOrAuto::Percentage(_)) {
709 b.margin.top = resolve_length_against(b.css_margin[0], available_width);
710 }
711 if matches!(b.css_margin[1], LengthOrAuto::Percentage(_)) {
712 b.margin.right = resolve_length_against(b.css_margin[1], available_width);
713 }
714 if matches!(b.css_margin[2], LengthOrAuto::Percentage(_)) {
715 b.margin.bottom = resolve_length_against(b.css_margin[2], available_width);
716 }
717 if matches!(b.css_margin[3], LengthOrAuto::Percentage(_)) {
718 b.margin.left = resolve_length_against(b.css_margin[3], available_width);
719 }
720
721 // Resolve percentage padding against containing block width.
722 if matches!(b.css_padding[0], LengthOrAuto::Percentage(_)) {
723 b.padding.top = resolve_length_against(b.css_padding[0], available_width);
724 }
725 if matches!(b.css_padding[1], LengthOrAuto::Percentage(_)) {
726 b.padding.right = resolve_length_against(b.css_padding[1], available_width);
727 }
728 if matches!(b.css_padding[2], LengthOrAuto::Percentage(_)) {
729 b.padding.bottom = resolve_length_against(b.css_padding[2], available_width);
730 }
731 if matches!(b.css_padding[3], LengthOrAuto::Percentage(_)) {
732 b.padding.left = resolve_length_against(b.css_padding[3], available_width);
733 }
734
735 let content_x = x + b.margin.left + b.border.left + b.padding.left;
736 let content_y = y + b.margin.top + b.border.top + b.padding.top;
737
738 let horizontal_extra = b.border.left + b.border.right + b.padding.left + b.padding.right;
739
740 // Resolve content width: explicit CSS width (adjusted for box-sizing) or auto.
741 let content_width = match b.css_width {
742 LengthOrAuto::Length(w) => match b.box_sizing {
743 BoxSizing::ContentBox => w.max(0.0),
744 BoxSizing::BorderBox => (w - horizontal_extra).max(0.0),
745 },
746 LengthOrAuto::Percentage(p) => {
747 let resolved = p / 100.0 * available_width;
748 match b.box_sizing {
749 BoxSizing::ContentBox => resolved.max(0.0),
750 BoxSizing::BorderBox => (resolved - horizontal_extra).max(0.0),
751 }
752 }
753 LengthOrAuto::Auto => {
754 (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0)
755 }
756 };
757
758 b.rect.x = content_x;
759 b.rect.y = content_y;
760 b.rect.width = content_width;
761
762 // For overflow:scroll, reserve space for vertical scrollbar.
763 if b.overflow == Overflow::Scroll {
764 b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0);
765 }
766
767 // Replaced elements (e.g., <img>) have intrinsic dimensions.
768 if let Some((rw, rh)) = b.replaced_size {
769 b.rect.width = rw.min(b.rect.width);
770 b.rect.height = rh;
771 set_sticky_constraints(b);
772 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc);
773 apply_relative_offset(b, available_width, viewport_height);
774 return;
775 }
776
777 match &b.box_type {
778 BoxType::Block(_) | BoxType::Anonymous => {
779 if matches!(b.display, Display::Flex | Display::InlineFlex) {
780 layout_flex_children(b, viewport_width, viewport_height, font, doc, abs_cb);
781 } else if has_block_children(b) || has_float_children(b) {
782 layout_block_children(b, viewport_width, viewport_height, font, doc, abs_cb);
783 } else {
784 layout_inline_children(b, font, doc, float_ctx);
785 }
786 }
787 BoxType::TextRun { .. } | BoxType::Inline(_) => {
788 // Handled by the parent's inline layout.
789 }
790 }
791
792 // Save the natural content height before CSS height override.
793 b.content_height = b.rect.height;
794
795 // Apply explicit CSS height (adjusted for box-sizing), overriding auto height.
796 match b.css_height {
797 LengthOrAuto::Length(h) => {
798 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom;
799 b.rect.height = match b.box_sizing {
800 BoxSizing::ContentBox => h.max(0.0),
801 BoxSizing::BorderBox => (h - vertical_extra).max(0.0),
802 };
803 }
804 LengthOrAuto::Percentage(p) => {
805 // Height percentage resolves against containing block height.
806 // For the root element, use viewport height.
807 let cb_height = viewport_height;
808 let resolved = p / 100.0 * cb_height;
809 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom;
810 b.rect.height = match b.box_sizing {
811 BoxSizing::ContentBox => resolved.max(0.0),
812 BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0),
813 };
814 }
815 LengthOrAuto::Auto => {}
816 }
817
818 // Set sticky constraint rects now that this box's dimensions are final.
819 set_sticky_constraints(b);
820
821 // Layout absolutely and fixed positioned children after this box's
822 // dimensions are fully resolved.
823 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc);
824
825 apply_relative_offset(b, available_width, viewport_height);
826}
827
828/// For each direct child with `position: sticky`, record the parent's content
829/// rect as the sticky constraint rectangle. This is called after the parent's
830/// dimensions are fully resolved so that the rect is accurate.
831fn set_sticky_constraints(parent: &mut LayoutBox) {
832 let content_rect = parent.rect;
833 for child in &mut parent.children {
834 if child.position == Position::Sticky {
835 child.sticky_constraint = Some(content_rect);
836 }
837 }
838}
839
840/// Apply `position: relative` offset to a box and all its descendants.
841///
842/// Resolves the CSS position offsets (which may contain percentages) and
843/// shifts the visual position without affecting the normal-flow layout.
844fn apply_relative_offset(b: &mut LayoutBox, cb_width: f32, cb_height: f32) {
845 if b.position != Position::Relative {
846 return;
847 }
848 let [top, right, bottom, left] = b.css_offsets;
849 let dx = resolve_relative_horizontal(left, right, cb_width);
850 let dy = resolve_relative_vertical(top, bottom, cb_height);
851 b.relative_offset = (dx, dy);
852 if dx == 0.0 && dy == 0.0 {
853 return;
854 }
855 shift_box(b, dx, dy);
856}
857
858/// Recursively shift a box and all its descendants by (dx, dy).
859fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) {
860 b.rect.x += dx;
861 b.rect.y += dy;
862 for line in &mut b.lines {
863 line.x += dx;
864 line.y += dy;
865 }
866 for child in &mut b.children {
867 shift_box(child, dx, dy);
868 }
869}
870
871// ---------------------------------------------------------------------------
872// Absolute / fixed positioning
873// ---------------------------------------------------------------------------
874
875/// Resolve a `LengthOrAuto` offset to an optional pixel value.
876/// Returns `None` for `Auto` (offset not specified).
877fn resolve_offset(value: LengthOrAuto, reference: f32) -> Option<f32> {
878 match value {
879 LengthOrAuto::Length(px) => Some(px),
880 LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference),
881 LengthOrAuto::Auto => None,
882 }
883}
884
885/// Compute the padding box rectangle for a layout box.
886fn padding_box_rect(b: &LayoutBox) -> Rect {
887 Rect {
888 x: b.rect.x - b.padding.left,
889 y: b.rect.y - b.padding.top,
890 width: b.rect.width + b.padding.left + b.padding.right,
891 height: b.rect.height + b.padding.top + b.padding.bottom,
892 }
893}
894
895/// Lay out all absolutely and fixed positioned children of `parent`.
896///
897/// `abs_cb` is the padding box of the nearest positioned ancestor passed from
898/// further up the tree. If `parent` itself is positioned (not `static`), its
899/// padding box becomes the new containing block for absolute descendants.
900fn layout_abspos_children(
901 parent: &mut LayoutBox,
902 abs_cb: Rect,
903 viewport_width: f32,
904 viewport_height: f32,
905 font: &Font,
906 doc: &Document,
907) {
908 let viewport_cb = Rect {
909 x: 0.0,
910 y: 0.0,
911 width: viewport_width,
912 height: viewport_height,
913 };
914
915 // If this box is positioned, it becomes the containing block for absolute
916 // descendants.
917 let new_abs_cb = if parent.position != Position::Static {
918 padding_box_rect(parent)
919 } else {
920 abs_cb
921 };
922
923 for i in 0..parent.children.len() {
924 if parent.children[i].position == Position::Absolute {
925 layout_absolute_child(
926 &mut parent.children[i],
927 new_abs_cb,
928 viewport_width,
929 viewport_height,
930 font,
931 doc,
932 );
933 } else if parent.children[i].position == Position::Fixed {
934 layout_absolute_child(
935 &mut parent.children[i],
936 viewport_cb,
937 viewport_width,
938 viewport_height,
939 font,
940 doc,
941 );
942 }
943 }
944}
945
946/// Lay out a single absolutely or fixed positioned element.
947///
948/// `cb` is the containing block (padding box of nearest positioned ancestor
949/// for absolute, or the viewport for fixed).
950fn layout_absolute_child(
951 child: &mut LayoutBox,
952 cb: Rect,
953 viewport_width: f32,
954 viewport_height: f32,
955 font: &Font,
956 doc: &Document,
957) {
958 let [css_top, css_right, css_bottom, css_left] = child.css_offsets;
959
960 // Resolve margins against containing block width.
961 child.margin = EdgeSizes {
962 top: resolve_length_against(child.css_margin[0], cb.width),
963 right: resolve_length_against(child.css_margin[1], cb.width),
964 bottom: resolve_length_against(child.css_margin[2], cb.width),
965 left: resolve_length_against(child.css_margin[3], cb.width),
966 };
967
968 // Resolve padding against containing block width.
969 child.padding = EdgeSizes {
970 top: resolve_length_against(child.css_padding[0], cb.width),
971 right: resolve_length_against(child.css_padding[1], cb.width),
972 bottom: resolve_length_against(child.css_padding[2], cb.width),
973 left: resolve_length_against(child.css_padding[3], cb.width),
974 };
975
976 let horiz_extra =
977 child.border.left + child.border.right + child.padding.left + child.padding.right;
978 let vert_extra =
979 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom;
980
981 // Resolve offsets.
982 let left_offset = resolve_offset(css_left, cb.width);
983 let right_offset = resolve_offset(css_right, cb.width);
984 let top_offset = resolve_offset(css_top, cb.height);
985 let bottom_offset = resolve_offset(css_bottom, cb.height);
986
987 // --- Resolve content width ---
988 let content_width = match child.css_width {
989 LengthOrAuto::Length(w) => match child.box_sizing {
990 BoxSizing::ContentBox => w.max(0.0),
991 BoxSizing::BorderBox => (w - horiz_extra).max(0.0),
992 },
993 LengthOrAuto::Percentage(p) => {
994 let resolved = p / 100.0 * cb.width;
995 match child.box_sizing {
996 BoxSizing::ContentBox => resolved.max(0.0),
997 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0),
998 }
999 }
1000 LengthOrAuto::Auto => {
1001 // If both left and right are specified, stretch to fill.
1002 if let (Some(l), Some(r)) = (left_offset, right_offset) {
1003 (cb.width - l - r - child.margin.left - child.margin.right - horiz_extra).max(0.0)
1004 } else {
1005 // Use available width minus margins.
1006 (cb.width - child.margin.left - child.margin.right - horiz_extra).max(0.0)
1007 }
1008 }
1009 };
1010
1011 child.rect.width = content_width;
1012
1013 // --- Resolve horizontal position ---
1014 child.rect.x = if let Some(l) = left_offset {
1015 cb.x + l + child.margin.left + child.border.left + child.padding.left
1016 } else if let Some(r) = right_offset {
1017 cb.x + cb.width
1018 - r
1019 - child.margin.right
1020 - child.border.right
1021 - child.padding.right
1022 - content_width
1023 } else {
1024 // Static position: containing block origin.
1025 cb.x + child.margin.left + child.border.left + child.padding.left
1026 };
1027
1028 // Set a temporary y for child content layout.
1029 child.rect.y = cb.y;
1030
1031 // For overflow:scroll, reserve scrollbar width.
1032 if child.overflow == Overflow::Scroll {
1033 child.rect.width = (child.rect.width - SCROLLBAR_WIDTH).max(0.0);
1034 }
1035
1036 // --- Layout child's own content ---
1037 if let Some((rw, rh)) = child.replaced_size {
1038 child.rect.width = rw.min(child.rect.width);
1039 child.rect.height = rh;
1040 } else {
1041 let child_abs_cb = if child.position != Position::Static {
1042 padding_box_rect(child)
1043 } else {
1044 cb
1045 };
1046 match &child.box_type {
1047 BoxType::Block(_) | BoxType::Anonymous => {
1048 if matches!(child.display, Display::Flex | Display::InlineFlex) {
1049 layout_flex_children(
1050 child,
1051 viewport_width,
1052 viewport_height,
1053 font,
1054 doc,
1055 child_abs_cb,
1056 );
1057 } else if has_block_children(child) || has_float_children(child) {
1058 layout_block_children(
1059 child,
1060 viewport_width,
1061 viewport_height,
1062 font,
1063 doc,
1064 child_abs_cb,
1065 );
1066 } else {
1067 layout_inline_children(child, font, doc, None);
1068 }
1069 }
1070 _ => {}
1071 }
1072 }
1073
1074 // Save natural content height.
1075 child.content_height = child.rect.height;
1076
1077 // --- Resolve content height ---
1078 match child.css_height {
1079 LengthOrAuto::Length(h) => {
1080 child.rect.height = match child.box_sizing {
1081 BoxSizing::ContentBox => h.max(0.0),
1082 BoxSizing::BorderBox => (h - vert_extra).max(0.0),
1083 };
1084 }
1085 LengthOrAuto::Percentage(p) => {
1086 let resolved = p / 100.0 * cb.height;
1087 child.rect.height = match child.box_sizing {
1088 BoxSizing::ContentBox => resolved.max(0.0),
1089 BoxSizing::BorderBox => (resolved - vert_extra).max(0.0),
1090 };
1091 }
1092 LengthOrAuto::Auto => {
1093 // If both top and bottom are specified, stretch.
1094 if let (Some(t), Some(b_val)) = (top_offset, bottom_offset) {
1095 child.rect.height =
1096 (cb.height - t - b_val - child.margin.top - child.margin.bottom - vert_extra)
1097 .max(0.0);
1098 }
1099 // Otherwise keep content height.
1100 }
1101 }
1102
1103 // --- Resolve vertical position ---
1104 let final_y = if let Some(t) = top_offset {
1105 cb.y + t + child.margin.top + child.border.top + child.padding.top
1106 } else if let Some(b_val) = bottom_offset {
1107 cb.y + cb.height
1108 - b_val
1109 - child.margin.bottom
1110 - child.border.bottom
1111 - child.padding.bottom
1112 - child.rect.height
1113 } else {
1114 // Static position: containing block origin.
1115 cb.y + child.margin.top + child.border.top + child.padding.top
1116 };
1117
1118 // Shift from temporary y to final y.
1119 let dy = final_y - child.rect.y;
1120 if dy != 0.0 {
1121 shift_box(child, 0.0, dy);
1122 }
1123
1124 // Set sticky constraints now that this child's dimensions are final.
1125 set_sticky_constraints(child);
1126
1127 // Recursively lay out any absolutely positioned grandchildren.
1128 let new_abs_cb = if child.position != Position::Static {
1129 padding_box_rect(child)
1130 } else {
1131 cb
1132 };
1133 layout_abspos_children(
1134 child,
1135 new_abs_cb,
1136 viewport_width,
1137 viewport_height,
1138 font,
1139 doc,
1140 );
1141}
1142
1143fn has_block_children(b: &LayoutBox) -> bool {
1144 b.children
1145 .iter()
1146 .any(|c| is_in_flow(c) && is_block_level(c))
1147}
1148
1149fn has_float_children(b: &LayoutBox) -> bool {
1150 b.children.iter().any(is_floated)
1151}
1152
1153/// Collapse two adjoining margins per CSS2 §8.3.1.
1154///
1155/// Both non-negative → use the larger.
1156/// Both negative → use the more negative.
1157/// Mixed → sum the largest positive and most negative.
1158fn collapse_margins(a: f32, b: f32) -> f32 {
1159 if a >= 0.0 && b >= 0.0 {
1160 a.max(b)
1161 } else if a < 0.0 && b < 0.0 {
1162 a.min(b)
1163 } else {
1164 a + b
1165 }
1166}
1167
1168/// Returns `true` if this box establishes a new block formatting context,
1169/// which prevents its margins from collapsing with children.
1170fn establishes_bfc(b: &LayoutBox) -> bool {
1171 b.overflow != Overflow::Visible
1172 || matches!(b.display, Display::Flex | Display::InlineFlex)
1173 || b.float != Float::None
1174}
1175
1176/// Returns `true` if a block box has no in-flow content (empty block).
1177fn is_empty_block(b: &LayoutBox) -> bool {
1178 b.children.is_empty()
1179 && b.lines.is_empty()
1180 && b.replaced_size.is_none()
1181 && matches!(b.css_height, LengthOrAuto::Auto)
1182}
1183
1184/// Pre-collapse parent-child margins (CSS2 §8.3.1).
1185///
1186/// When a parent has no border/padding/BFC separating it from its first/last
1187/// child, the child's margin collapses into the parent's margin. This must
1188/// happen *before* positioning so the parent is placed using the collapsed
1189/// value. The function walks bottom-up: children are pre-collapsed first, then
1190/// their (possibly enlarged) margins are folded into the parent.
1191fn pre_collapse_margins(b: &mut LayoutBox) {
1192 // Recurse into in-flow block children first (bottom-up).
1193 for child in &mut b.children {
1194 if is_in_flow(child) && is_block_level(child) {
1195 pre_collapse_margins(child);
1196 }
1197 }
1198
1199 if !matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) {
1200 return;
1201 }
1202 if establishes_bfc(b) {
1203 return;
1204 }
1205 if !has_block_children(b) {
1206 return;
1207 }
1208
1209 // --- Top: collapse with first non-empty child ---
1210 if b.border.top == 0.0 && b.padding.top == 0.0 {
1211 if let Some(child_top) = first_block_top_margin(&b.children) {
1212 b.margin.top = collapse_margins(b.margin.top, child_top);
1213 }
1214 }
1215
1216 // --- Bottom: collapse with last non-empty child ---
1217 if b.border.bottom == 0.0 && b.padding.bottom == 0.0 {
1218 if let Some(child_bottom) = last_block_bottom_margin(&b.children) {
1219 b.margin.bottom = collapse_margins(b.margin.bottom, child_bottom);
1220 }
1221 }
1222}
1223
1224/// Top margin of the first non-empty in-flow block child (already pre-collapsed).
1225fn first_block_top_margin(children: &[LayoutBox]) -> Option<f32> {
1226 for child in children {
1227 if is_in_flow(child) && is_block_level(child) {
1228 if is_empty_block(child) {
1229 continue;
1230 }
1231 return Some(child.margin.top);
1232 }
1233 }
1234 // All in-flow block children empty — fold all their collapsed margins.
1235 let mut m = 0.0f32;
1236 for child in children
1237 .iter()
1238 .filter(|c| is_in_flow(c) && is_block_level(c))
1239 {
1240 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom));
1241 }
1242 if m != 0.0 {
1243 Some(m)
1244 } else {
1245 None
1246 }
1247}
1248
1249/// Bottom margin of the last non-empty in-flow block child (already pre-collapsed).
1250fn last_block_bottom_margin(children: &[LayoutBox]) -> Option<f32> {
1251 for child in children.iter().rev() {
1252 if is_in_flow(child) && is_block_level(child) {
1253 if is_empty_block(child) {
1254 continue;
1255 }
1256 return Some(child.margin.bottom);
1257 }
1258 }
1259 let mut m = 0.0f32;
1260 for child in children
1261 .iter()
1262 .filter(|c| is_in_flow(c) && is_block_level(c))
1263 {
1264 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom));
1265 }
1266 if m != 0.0 {
1267 Some(m)
1268 } else {
1269 None
1270 }
1271}
1272
1273/// Lay out block-level children with vertical margin collapsing (CSS2 §8.3.1)
1274/// and float support.
1275///
1276/// Handles adjacent-sibling collapsing, empty-block collapsing,
1277/// parent-child internal spacing (the parent's external margins were already
1278/// updated by `pre_collapse_margins`), and float placement.
1279fn layout_block_children(
1280 parent: &mut LayoutBox,
1281 viewport_width: f32,
1282 viewport_height: f32,
1283 font: &Font,
1284 doc: &Document,
1285 abs_cb: Rect,
1286) {
1287 let content_x = parent.rect.x;
1288 let content_width = parent.rect.width;
1289 let mut cursor_y = parent.rect.y;
1290
1291 let parent_top_open =
1292 parent.border.top == 0.0 && parent.padding.top == 0.0 && !establishes_bfc(parent);
1293 let parent_bottom_open =
1294 parent.border.bottom == 0.0 && parent.padding.bottom == 0.0 && !establishes_bfc(parent);
1295
1296 // Pending bottom margin from the previous sibling.
1297 let mut pending_margin: Option<f32> = None;
1298 let child_count = parent.children.len();
1299 // Track whether we've seen any in-flow children (for parent_top_open).
1300 let mut first_in_flow = true;
1301 let mut float_ctx = FloatContext::default();
1302
1303 for i in 0..child_count {
1304 // Skip out-of-flow children (absolute/fixed) — they are laid out
1305 // separately in layout_abspos_children.
1306 if parent.children[i].position == Position::Absolute
1307 || parent.children[i].position == Position::Fixed
1308 {
1309 continue;
1310 }
1311
1312 // --- Handle floated children ---
1313 if is_floated(&parent.children[i]) {
1314 layout_float_child(
1315 &mut parent.children[i],
1316 &mut float_ctx,
1317 cursor_y,
1318 content_x,
1319 content_width,
1320 viewport_width,
1321 viewport_height,
1322 font,
1323 doc,
1324 abs_cb,
1325 );
1326 continue;
1327 }
1328
1329 let child_top_margin = parent.children[i].margin.top;
1330 let child_bottom_margin = parent.children[i].margin.bottom;
1331
1332 // --- Handle clear property ---
1333 if parent.children[i].clear != Clear::None {
1334 let clear_y = float_ctx.clear_y(parent.children[i].clear);
1335 if clear_y > cursor_y {
1336 cursor_y = clear_y;
1337 // Clear resets pending margin.
1338 pending_margin = None;
1339 }
1340 }
1341
1342 // --- Empty block: top+bottom margins self-collapse ---
1343 if is_empty_block(&parent.children[i]) {
1344 let self_collapsed = collapse_margins(child_top_margin, child_bottom_margin);
1345 pending_margin = Some(match pending_margin {
1346 Some(prev) => collapse_margins(prev, self_collapsed),
1347 None => self_collapsed,
1348 });
1349 // Position at cursor_y with zero height.
1350 let child = &mut parent.children[i];
1351 child.rect.x = content_x + child.border.left + child.padding.left;
1352 child.rect.y = cursor_y + child.border.top + child.padding.top;
1353 child.rect.width = (content_width
1354 - child.border.left
1355 - child.border.right
1356 - child.padding.left
1357 - child.padding.right)
1358 .max(0.0);
1359 child.rect.height = 0.0;
1360 first_in_flow = false;
1361 continue;
1362 }
1363
1364 // --- Compute effective top spacing ---
1365 let collapsed_top = if let Some(prev_bottom) = pending_margin.take() {
1366 // Sibling collapsing: previous bottom vs this top.
1367 collapse_margins(prev_bottom, child_top_margin)
1368 } else if first_in_flow && parent_top_open {
1369 // First child, parent top open: margin was already pulled into
1370 // parent by pre_collapse_margins — no internal spacing.
1371 0.0
1372 } else {
1373 child_top_margin
1374 };
1375
1376 // `compute_layout` adds `child.margin.top` internally, so compensate.
1377 let y_for_child = cursor_y + collapsed_top - child_top_margin;
1378 compute_layout(
1379 &mut parent.children[i],
1380 content_x,
1381 y_for_child,
1382 content_width,
1383 viewport_width,
1384 viewport_height,
1385 font,
1386 doc,
1387 abs_cb,
1388 Some(&float_ctx),
1389 );
1390
1391 let child = &parent.children[i];
1392 // Use the normal-flow position (before relative offset) so that
1393 // `position: relative` does not affect sibling placement.
1394 let (_, rel_dy) = child.relative_offset;
1395 cursor_y = (child.rect.y - rel_dy)
1396 + child.rect.height
1397 + child.padding.bottom
1398 + child.border.bottom;
1399 pending_margin = Some(child_bottom_margin);
1400 first_in_flow = false;
1401 }
1402
1403 // Trailing margin.
1404 if let Some(trailing) = pending_margin {
1405 if !parent_bottom_open {
1406 // Parent has border/padding at bottom — margin stays inside.
1407 cursor_y += trailing;
1408 }
1409 // If parent_bottom_open, the margin was already pulled into the
1410 // parent by pre_collapse_margins.
1411 }
1412
1413 parent.rect.height = cursor_y - parent.rect.y;
1414
1415 // BFC containment: if this box establishes a BFC, it must expand to
1416 // contain all of its floated children.
1417 if establishes_bfc(parent) {
1418 let float_bottom = float_ctx.max_float_bottom();
1419 let needed = float_bottom - parent.rect.y;
1420 if needed > parent.rect.height {
1421 parent.rect.height = needed;
1422 }
1423 }
1424}
1425
1426/// Lay out a single floated child element.
1427#[allow(clippy::too_many_arguments)]
1428fn layout_float_child(
1429 child: &mut LayoutBox,
1430 float_ctx: &mut FloatContext,
1431 cursor_y: f32,
1432 container_x: f32,
1433 container_width: f32,
1434 viewport_width: f32,
1435 viewport_height: f32,
1436 font: &Font,
1437 doc: &Document,
1438 abs_cb: Rect,
1439) {
1440 let float_side = child.float;
1441
1442 // Resolve margins against containing block width.
1443 child.margin = EdgeSizes {
1444 top: resolve_length_against(child.css_margin[0], container_width),
1445 right: resolve_length_against(child.css_margin[1], container_width),
1446 bottom: resolve_length_against(child.css_margin[2], container_width),
1447 left: resolve_length_against(child.css_margin[3], container_width),
1448 };
1449
1450 // Resolve padding against containing block width.
1451 child.padding = EdgeSizes {
1452 top: resolve_length_against(child.css_padding[0], container_width),
1453 right: resolve_length_against(child.css_padding[1], container_width),
1454 bottom: resolve_length_against(child.css_padding[2], container_width),
1455 left: resolve_length_against(child.css_padding[3], container_width),
1456 };
1457
1458 let horiz_extra =
1459 child.border.left + child.border.right + child.padding.left + child.padding.right;
1460
1461 // Resolve content width.
1462 let content_width = match child.css_width {
1463 LengthOrAuto::Length(w) => match child.box_sizing {
1464 BoxSizing::ContentBox => w.max(0.0),
1465 BoxSizing::BorderBox => (w - horiz_extra).max(0.0),
1466 },
1467 LengthOrAuto::Percentage(p) => {
1468 let resolved = p / 100.0 * container_width;
1469 match child.box_sizing {
1470 BoxSizing::ContentBox => resolved.max(0.0),
1471 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0),
1472 }
1473 }
1474 LengthOrAuto::Auto => {
1475 // Shrink-to-fit: measure content.
1476 let max_content = measure_float_content_width(child, font);
1477 let available =
1478 (container_width - child.margin.left - child.margin.right - horiz_extra).max(0.0);
1479 max_content.min(available)
1480 }
1481 };
1482
1483 child.rect.width = content_width;
1484
1485 // Temporary position for layout.
1486 child.rect.x = container_x + child.margin.left + child.border.left + child.padding.left;
1487 child.rect.y = cursor_y + child.margin.top + child.border.top + child.padding.top;
1488
1489 // Layout child content.
1490 if let Some((rw, rh)) = child.replaced_size {
1491 child.rect.width = rw.min(child.rect.width);
1492 child.rect.height = rh;
1493 } else {
1494 match &child.box_type {
1495 BoxType::Block(_) | BoxType::Anonymous => {
1496 if matches!(child.display, Display::Flex | Display::InlineFlex) {
1497 layout_flex_children(child, viewport_width, viewport_height, font, doc, abs_cb);
1498 } else if has_block_children(child) || has_float_children(child) {
1499 layout_block_children(
1500 child,
1501 viewport_width,
1502 viewport_height,
1503 font,
1504 doc,
1505 abs_cb,
1506 );
1507 } else {
1508 layout_inline_children(child, font, doc, None);
1509 }
1510 }
1511 _ => {}
1512 }
1513 }
1514
1515 // Resolve explicit CSS height.
1516 child.content_height = child.rect.height;
1517 match child.css_height {
1518 LengthOrAuto::Length(h) => {
1519 let vert_extra =
1520 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom;
1521 child.rect.height = match child.box_sizing {
1522 BoxSizing::ContentBox => h.max(0.0),
1523 BoxSizing::BorderBox => (h - vert_extra).max(0.0),
1524 };
1525 }
1526 LengthOrAuto::Percentage(p) => {
1527 let resolved = p / 100.0 * viewport_height;
1528 let vert_extra =
1529 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom;
1530 child.rect.height = match child.box_sizing {
1531 BoxSizing::ContentBox => resolved.max(0.0),
1532 BoxSizing::BorderBox => (resolved - vert_extra).max(0.0),
1533 };
1534 }
1535 LengthOrAuto::Auto => {}
1536 }
1537
1538 // Compute the float's margin box dimensions.
1539 let margin_box_width = child.margin.left
1540 + child.border.left
1541 + child.padding.left
1542 + child.rect.width
1543 + child.padding.right
1544 + child.border.right
1545 + child.margin.right;
1546 let margin_box_height = child.margin.top
1547 + child.border.top
1548 + child.padding.top
1549 + child.rect.height
1550 + child.padding.bottom
1551 + child.border.bottom
1552 + child.margin.bottom;
1553
1554 // Place the float.
1555 let (fx, fy) = float_ctx.place_float(
1556 float_side,
1557 margin_box_width,
1558 margin_box_height,
1559 cursor_y,
1560 container_x,
1561 container_width,
1562 );
1563
1564 // Position the child's content box relative to the placed margin box.
1565 let final_x = fx + child.margin.left + child.border.left + child.padding.left;
1566 let final_y = fy + child.margin.top + child.border.top + child.padding.top;
1567
1568 // Shift the entire box tree from its temporary position to the final one.
1569 let dx = final_x - child.rect.x;
1570 let dy = final_y - child.rect.y;
1571 if dx != 0.0 || dy != 0.0 {
1572 shift_box(child, dx, dy);
1573 }
1574
1575 // Set sticky constraints and handle abspos children.
1576 set_sticky_constraints(child);
1577 layout_abspos_children(child, abs_cb, viewport_width, viewport_height, font, doc);
1578 apply_relative_offset(child, container_width, viewport_height);
1579}
1580
1581/// Measure the max-content width of a float's content (shrink-to-fit).
1582fn measure_float_content_width(b: &LayoutBox, font: &Font) -> f32 {
1583 let mut max_width = 0.0f32;
1584 measure_box_content_width(b, font, &mut max_width);
1585 max_width
1586}
1587
1588// ---------------------------------------------------------------------------
1589// Flex layout
1590// ---------------------------------------------------------------------------
1591
1592/// Measure the intrinsic max-content width of a flex item's content.
1593/// Returns the width needed to lay out content without wrapping.
1594fn measure_flex_item_max_content_width(child: &LayoutBox, font: &Font, _doc: &Document) -> f32 {
1595 // Measure the widest text line from all inline content.
1596 let mut max_width = 0.0f32;
1597 measure_box_content_width(child, font, &mut max_width);
1598 max_width
1599}
1600
1601/// Recursively measure max content width of a layout box tree.
1602fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) {
1603 match &b.box_type {
1604 BoxType::TextRun { text, .. } => {
1605 let w = measure_text_width(font, text, b.font_size);
1606 if w > *max_w {
1607 *max_w = w;
1608 }
1609 }
1610 _ => {
1611 let horiz = b.border.left + b.border.right + b.padding.left + b.padding.right;
1612 let mut child_max = 0.0f32;
1613 for child in &b.children {
1614 measure_box_content_width(child, font, &mut child_max);
1615 }
1616 let total = child_max + horiz;
1617 if total > *max_w {
1618 *max_w = total;
1619 }
1620 }
1621 }
1622}
1623
1624/// Lay out children according to the CSS Flexbox algorithm (Level 1 §9).
1625fn layout_flex_children(
1626 parent: &mut LayoutBox,
1627 viewport_width: f32,
1628 viewport_height: f32,
1629 font: &Font,
1630 doc: &Document,
1631 abs_cb: Rect,
1632) {
1633 let flex_direction = parent.flex_direction;
1634 let flex_wrap = parent.flex_wrap;
1635 let justify_content = parent.justify_content;
1636 let align_items = parent.align_items;
1637 let align_content = parent.align_content;
1638 let container_main_size = match flex_direction {
1639 FlexDirection::Row | FlexDirection::RowReverse => parent.rect.width,
1640 FlexDirection::Column | FlexDirection::ColumnReverse => {
1641 match parent.css_height {
1642 LengthOrAuto::Length(h) => h,
1643 LengthOrAuto::Percentage(p) => p / 100.0 * viewport_height,
1644 LengthOrAuto::Auto => viewport_height, // fallback
1645 }
1646 }
1647 };
1648 let container_cross_size = match flex_direction {
1649 FlexDirection::Row | FlexDirection::RowReverse => match parent.css_height {
1650 LengthOrAuto::Length(h) => Some(h),
1651 LengthOrAuto::Percentage(p) => Some(p / 100.0 * viewport_height),
1652 LengthOrAuto::Auto => None,
1653 },
1654 FlexDirection::Column | FlexDirection::ColumnReverse => Some(parent.rect.width),
1655 };
1656
1657 let is_row = matches!(
1658 flex_direction,
1659 FlexDirection::Row | FlexDirection::RowReverse
1660 );
1661 let is_reverse = matches!(
1662 flex_direction,
1663 FlexDirection::RowReverse | FlexDirection::ColumnReverse
1664 );
1665
1666 // Per CSS Box Alignment §8, row-gap applies between rows and column-gap
1667 // between columns. In a row flex container the main axis is horizontal
1668 // (column-gap between items, row-gap between lines). In a column flex
1669 // container the axes are swapped.
1670 let (main_gap, cross_gap) = if is_row {
1671 (parent.column_gap, parent.row_gap)
1672 } else {
1673 (parent.row_gap, parent.column_gap)
1674 };
1675
1676 if parent.children.is_empty() {
1677 parent.rect.height = 0.0;
1678 return;
1679 }
1680
1681 // Sort children by `order` (stable sort preserves DOM order for equal values).
1682 let child_count = parent.children.len();
1683 let mut order_indices: Vec<usize> = (0..child_count).collect();
1684 order_indices.sort_by_key(|&i| parent.children[i].order);
1685
1686 // Step 1: Determine flex base sizes and hypothetical main sizes.
1687 struct FlexItemInfo {
1688 index: usize,
1689 base_size: f32,
1690 hypothetical_main: f32,
1691 flex_grow: f32,
1692 flex_shrink: f32,
1693 frozen: bool,
1694 target_main: f32,
1695 outer_main: f32, // margin+border+padding on main axis
1696 outer_cross: f32, // margin+border+padding on cross axis
1697 }
1698
1699 let mut items: Vec<FlexItemInfo> = Vec::with_capacity(child_count);
1700
1701 for &idx in &order_indices {
1702 // Skip out-of-flow children (absolute/fixed).
1703 if !is_in_flow(&parent.children[idx]) {
1704 continue;
1705 }
1706 let child = &mut parent.children[idx];
1707
1708 // Resolve margin/padding percentages for the child.
1709 let cb_width = if is_row {
1710 container_main_size
1711 } else {
1712 container_cross_size.unwrap_or(parent.rect.width)
1713 };
1714
1715 // Resolve percentage margins and padding against containing block width.
1716 for i in 0..4 {
1717 if matches!(child.css_margin[i], LengthOrAuto::Percentage(_)) {
1718 let resolved = resolve_length_against(child.css_margin[i], cb_width);
1719 match i {
1720 0 => child.margin.top = resolved,
1721 1 => child.margin.right = resolved,
1722 2 => child.margin.bottom = resolved,
1723 3 => child.margin.left = resolved,
1724 _ => {}
1725 }
1726 }
1727 if matches!(child.css_padding[i], LengthOrAuto::Percentage(_)) {
1728 let resolved = resolve_length_against(child.css_padding[i], cb_width);
1729 match i {
1730 0 => child.padding.top = resolved,
1731 1 => child.padding.right = resolved,
1732 2 => child.padding.bottom = resolved,
1733 3 => child.padding.left = resolved,
1734 _ => {}
1735 }
1736 }
1737 }
1738
1739 let margin_main = if is_row {
1740 child.margin.left + child.margin.right
1741 } else {
1742 child.margin.top + child.margin.bottom
1743 };
1744 let border_padding_main = if is_row {
1745 child.border.left + child.border.right + child.padding.left + child.padding.right
1746 } else {
1747 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom
1748 };
1749 let margin_cross = if is_row {
1750 child.margin.top + child.margin.bottom
1751 } else {
1752 child.margin.left + child.margin.right
1753 };
1754 let border_padding_cross = if is_row {
1755 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom
1756 } else {
1757 child.border.left + child.border.right + child.padding.left + child.padding.right
1758 };
1759
1760 // Determine flex-basis.
1761 let flex_basis = child.flex_basis;
1762 let specified_main = if is_row {
1763 child.css_width
1764 } else {
1765 child.css_height
1766 };
1767
1768 let base_size = match flex_basis {
1769 LengthOrAuto::Length(px) => px,
1770 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size,
1771 LengthOrAuto::Auto => {
1772 // Use specified main size if set, otherwise content size.
1773 match specified_main {
1774 LengthOrAuto::Length(px) => px,
1775 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size,
1776 LengthOrAuto::Auto => {
1777 // Content-based sizing: use max-content size.
1778 if is_row {
1779 measure_flex_item_max_content_width(child, font, doc)
1780 } else {
1781 // For column direction, measure content height.
1782 let avail = container_cross_size.unwrap_or(parent.rect.width);
1783 compute_layout(
1784 child,
1785 0.0,
1786 0.0,
1787 avail,
1788 viewport_width,
1789 viewport_height,
1790 font,
1791 doc,
1792 abs_cb,
1793 None,
1794 );
1795 child.rect.height
1796 }
1797 }
1798 }
1799 }
1800 };
1801
1802 let hypothetical_main = base_size.max(0.0);
1803 let outer = margin_main + border_padding_main;
1804
1805 items.push(FlexItemInfo {
1806 index: idx,
1807 base_size,
1808 hypothetical_main,
1809 flex_grow: child.flex_grow,
1810 flex_shrink: child.flex_shrink,
1811 frozen: false,
1812 target_main: hypothetical_main,
1813 outer_main: outer,
1814 outer_cross: margin_cross + border_padding_cross,
1815 });
1816 }
1817
1818 // Step 2: Collect items into flex lines.
1819 let mut lines: Vec<Vec<usize>> = Vec::new(); // indices into `items`
1820
1821 if flex_wrap == FlexWrap::Nowrap {
1822 // All items on one line.
1823 lines.push((0..items.len()).collect());
1824 } else {
1825 let mut current_line: Vec<usize> = Vec::new();
1826 let mut line_main_size = 0.0f32;
1827
1828 for (i, item) in items.iter().enumerate() {
1829 let item_outer = item.hypothetical_main + item.outer_main;
1830 let gap = if current_line.is_empty() {
1831 0.0
1832 } else {
1833 main_gap
1834 };
1835
1836 if !current_line.is_empty() && line_main_size + gap + item_outer > container_main_size {
1837 lines.push(std::mem::take(&mut current_line));
1838 line_main_size = 0.0;
1839 }
1840
1841 if !current_line.is_empty() {
1842 line_main_size += main_gap;
1843 }
1844 line_main_size += item_outer;
1845 current_line.push(i);
1846 }
1847
1848 if !current_line.is_empty() {
1849 lines.push(current_line);
1850 }
1851 }
1852
1853 if flex_wrap == FlexWrap::WrapReverse {
1854 lines.reverse();
1855 }
1856
1857 // Step 3: Resolve flexible lengths for each line.
1858 for line in &lines {
1859 // Total hypothetical main sizes + gaps.
1860 let total_gaps = if line.len() > 1 {
1861 (line.len() - 1) as f32 * main_gap
1862 } else {
1863 0.0
1864 };
1865 let total_outer_hypo: f32 = line
1866 .iter()
1867 .map(|&i| items[i].hypothetical_main + items[i].outer_main)
1868 .sum();
1869 let used_space = total_outer_hypo + total_gaps;
1870 let free_space = container_main_size - used_space;
1871
1872 // Reset frozen state.
1873 for &i in line {
1874 items[i].frozen = false;
1875 items[i].target_main = items[i].hypothetical_main;
1876 }
1877
1878 if free_space > 0.0 {
1879 // Grow items.
1880 let total_grow: f32 = line.iter().map(|&i| items[i].flex_grow).sum();
1881 if total_grow > 0.0 {
1882 for &i in line {
1883 if items[i].flex_grow > 0.0 {
1884 items[i].target_main += free_space * (items[i].flex_grow / total_grow);
1885 }
1886 }
1887 }
1888 } else if free_space < 0.0 {
1889 // Shrink items.
1890 let total_shrink_scaled: f32 = line
1891 .iter()
1892 .map(|&i| items[i].flex_shrink * items[i].base_size)
1893 .sum();
1894 if total_shrink_scaled > 0.0 {
1895 for &i in line {
1896 let scaled = items[i].flex_shrink * items[i].base_size;
1897 let ratio = scaled / total_shrink_scaled;
1898 items[i].target_main =
1899 (items[i].hypothetical_main + free_space * ratio).max(0.0);
1900 }
1901 }
1902 }
1903 }
1904
1905 // Step 4: Determine cross sizes of items by laying them out at their target main size.
1906 struct LineCrossInfo {
1907 max_cross: f32,
1908 }
1909
1910 let mut line_infos: Vec<LineCrossInfo> = Vec::new();
1911
1912 for line in &lines {
1913 let mut max_cross = 0.0f32;
1914
1915 for &i in line {
1916 let idx = items[i].index;
1917 let child = &mut parent.children[idx];
1918 let target_main = items[i].target_main;
1919
1920 // Set up the child for layout at the resolved main size.
1921 if is_row {
1922 child.css_width = LengthOrAuto::Length(target_main);
1923 compute_layout(
1924 child,
1925 0.0,
1926 0.0,
1927 target_main,
1928 viewport_width,
1929 viewport_height,
1930 font,
1931 doc,
1932 abs_cb,
1933 None,
1934 );
1935 let cross = child.rect.height + items[i].outer_cross;
1936 if cross > max_cross {
1937 max_cross = cross;
1938 }
1939 } else {
1940 let avail = container_cross_size.unwrap_or(parent.rect.width);
1941 compute_layout(
1942 child,
1943 0.0,
1944 0.0,
1945 avail,
1946 viewport_width,
1947 viewport_height,
1948 font,
1949 doc,
1950 abs_cb,
1951 None,
1952 );
1953 child.rect.height = target_main;
1954 let cross = child.rect.width
1955 + child.border.left
1956 + child.border.right
1957 + child.padding.left
1958 + child.padding.right
1959 + child.margin.left
1960 + child.margin.right;
1961 if cross > max_cross {
1962 max_cross = cross;
1963 }
1964 }
1965 }
1966
1967 line_infos.push(LineCrossInfo { max_cross });
1968 }
1969
1970 // Per CSS Flexbox §9.4: If the flex container is single-line and has a
1971 // definite cross size, the cross size of the flex line is the container's
1972 // cross size (clamped to min/max). This ensures alignment works relative
1973 // to the full container.
1974 if lines.len() == 1 {
1975 if let Some(cs) = container_cross_size {
1976 if line_infos[0].max_cross < cs {
1977 line_infos[0].max_cross = cs;
1978 }
1979 }
1980 }
1981
1982 // Step 5: Handle align-items: stretch — stretch items to fill the line cross size.
1983 for (line_idx, line) in lines.iter().enumerate() {
1984 let line_cross = line_infos[line_idx].max_cross;
1985
1986 for &i in line {
1987 let idx = items[i].index;
1988 let child = &mut parent.children[idx];
1989
1990 let effective_align = match child.align_self {
1991 AlignSelf::Auto => align_items,
1992 AlignSelf::FlexStart => AlignItems::FlexStart,
1993 AlignSelf::FlexEnd => AlignItems::FlexEnd,
1994 AlignSelf::Center => AlignItems::Center,
1995 AlignSelf::Baseline => AlignItems::Baseline,
1996 AlignSelf::Stretch => AlignItems::Stretch,
1997 };
1998
1999 if effective_align == AlignItems::Stretch {
2000 let item_cross_space = line_cross - items[i].outer_cross;
2001 if is_row {
2002 if matches!(child.css_height, LengthOrAuto::Auto) {
2003 child.rect.height = item_cross_space.max(0.0);
2004 }
2005 } else if matches!(child.css_width, LengthOrAuto::Auto) {
2006 child.rect.width = item_cross_space.max(0.0);
2007 }
2008 }
2009 }
2010 }
2011
2012 // Step 6: Position items on main and cross axes.
2013 let total_cross_gaps = if lines.len() > 1 {
2014 (lines.len() - 1) as f32 * cross_gap
2015 } else {
2016 0.0
2017 };
2018 let total_line_cross: f32 = line_infos.iter().map(|li| li.max_cross).sum();
2019 let total_cross_used = total_line_cross + total_cross_gaps;
2020
2021 // For definite cross size containers, compute align-content offsets.
2022 let cross_free_space = container_cross_size
2023 .map(|cs| (cs - total_cross_used).max(0.0))
2024 .unwrap_or(0.0);
2025
2026 let (ac_initial_offset, ac_between_offset) = compute_content_distribution(
2027 align_content_to_justify(align_content),
2028 cross_free_space,
2029 lines.len(),
2030 );
2031
2032 let mut cross_cursor = if is_row { parent.rect.y } else { parent.rect.x } + ac_initial_offset;
2033
2034 for (line_idx, line) in lines.iter().enumerate() {
2035 let line_cross = line_infos[line_idx].max_cross;
2036
2037 // Main-axis justification.
2038 let total_main_gaps = if line.len() > 1 {
2039 (line.len() - 1) as f32 * main_gap
2040 } else {
2041 0.0
2042 };
2043 let items_main: f32 = line
2044 .iter()
2045 .map(|&i| items[i].target_main + items[i].outer_main)
2046 .sum();
2047 let main_free = (container_main_size - items_main - total_main_gaps).max(0.0);
2048
2049 let (jc_initial, jc_between) =
2050 compute_content_distribution(justify_content, main_free, line.len());
2051
2052 // Determine the starting main position.
2053 let mut main_cursor = if is_row { parent.rect.x } else { parent.rect.y };
2054
2055 if is_reverse {
2056 // For reverse directions, start from the end.
2057 main_cursor += container_main_size;
2058 }
2059
2060 main_cursor += if is_reverse { -jc_initial } else { jc_initial };
2061
2062 let line_items: Vec<usize> = if is_reverse {
2063 line.iter().rev().copied().collect()
2064 } else {
2065 line.to_vec()
2066 };
2067
2068 for (item_pos, &i) in line_items.iter().enumerate() {
2069 let idx = items[i].index;
2070 let child = &mut parent.children[idx];
2071 let target_main = items[i].target_main;
2072
2073 // Main-axis position.
2074 let child_main_margin_start = if is_row {
2075 child.margin.left
2076 } else {
2077 child.margin.top
2078 };
2079 let child_main_margin_end = if is_row {
2080 child.margin.right
2081 } else {
2082 child.margin.bottom
2083 };
2084 let child_main_bp_start = if is_row {
2085 child.border.left + child.padding.left
2086 } else {
2087 child.border.top + child.padding.top
2088 };
2089 let child_main_bp_end = if is_row {
2090 child.border.right + child.padding.right
2091 } else {
2092 child.border.bottom + child.padding.bottom
2093 };
2094
2095 let outer_size = child_main_margin_start
2096 + child_main_bp_start
2097 + target_main
2098 + child_main_bp_end
2099 + child_main_margin_end;
2100
2101 if is_reverse {
2102 main_cursor -= outer_size;
2103 }
2104
2105 let content_main = main_cursor + child_main_margin_start + child_main_bp_start;
2106
2107 // Cross-axis position.
2108 let child_cross_margin_start = if is_row {
2109 child.margin.top
2110 } else {
2111 child.margin.left
2112 };
2113 let child_cross_bp_start = if is_row {
2114 child.border.top + child.padding.top
2115 } else {
2116 child.border.left + child.padding.left
2117 };
2118 let child_cross_total = if is_row {
2119 child.rect.height + items[i].outer_cross
2120 } else {
2121 child.rect.width + items[i].outer_cross
2122 };
2123
2124 let effective_align = match child.align_self {
2125 AlignSelf::Auto => align_items,
2126 AlignSelf::FlexStart => AlignItems::FlexStart,
2127 AlignSelf::FlexEnd => AlignItems::FlexEnd,
2128 AlignSelf::Center => AlignItems::Center,
2129 AlignSelf::Baseline => AlignItems::Baseline,
2130 AlignSelf::Stretch => AlignItems::Stretch,
2131 };
2132
2133 let cross_offset = match effective_align {
2134 AlignItems::FlexStart | AlignItems::Stretch | AlignItems::Baseline => 0.0,
2135 AlignItems::FlexEnd => line_cross - child_cross_total,
2136 AlignItems::Center => (line_cross - child_cross_total) / 2.0,
2137 };
2138
2139 let content_cross =
2140 cross_cursor + cross_offset + child_cross_margin_start + child_cross_bp_start;
2141
2142 // Set child position.
2143 if is_row {
2144 child.rect.x = content_main;
2145 child.rect.y = content_cross;
2146 child.rect.width = target_main;
2147 } else {
2148 child.rect.x = content_cross;
2149 child.rect.y = content_main;
2150 child.rect.height = target_main;
2151 }
2152
2153 // Shift any text lines to match new position.
2154 reposition_lines(child, font, doc);
2155
2156 if !is_reverse {
2157 main_cursor += outer_size;
2158 }
2159
2160 // Add gap between items (not after last).
2161 if item_pos < line_items.len() - 1 {
2162 if is_reverse {
2163 main_cursor -= main_gap;
2164 } else {
2165 main_cursor += main_gap;
2166 }
2167 }
2168
2169 // Also add justify-content between spacing.
2170 if item_pos < line_items.len() - 1 {
2171 if is_reverse {
2172 main_cursor -= jc_between;
2173 } else {
2174 main_cursor += jc_between;
2175 }
2176 }
2177 }
2178
2179 cross_cursor += line_cross + cross_gap + ac_between_offset;
2180 }
2181
2182 // Set parent height based on children.
2183 if is_row {
2184 if matches!(parent.css_height, LengthOrAuto::Auto) {
2185 parent.rect.height = total_cross_used;
2186 }
2187 } else if matches!(parent.css_height, LengthOrAuto::Auto) {
2188 // For column flex, height is the main axis.
2189 let total_main: f32 = lines
2190 .iter()
2191 .map(|line| {
2192 let items_main: f32 = line
2193 .iter()
2194 .map(|&i| items[i].target_main + items[i].outer_main)
2195 .sum();
2196 let gaps = if line.len() > 1 {
2197 (line.len() - 1) as f32 * main_gap
2198 } else {
2199 0.0
2200 };
2201 items_main + gaps
2202 })
2203 .fold(0.0f32, f32::max);
2204 parent.rect.height = total_main;
2205 }
2206}
2207
2208/// Reposition inline text lines inside a layout box after moving it.
2209/// Re-runs inline layout if the box has inline children.
2210fn reposition_lines(b: &mut LayoutBox, font: &Font, doc: &Document) {
2211 if !b.lines.is_empty() {
2212 // Re-run inline layout at the new position.
2213 b.lines.clear();
2214 layout_inline_children(b, font, doc, None);
2215 }
2216 // Recursively reposition children that have their own inline content.
2217 for child in &mut b.children {
2218 if !child.lines.is_empty() {
2219 child.lines.clear();
2220 layout_inline_children(child, font, doc, None);
2221 }
2222 }
2223}
2224
2225/// Convert AlignContent to JustifyContent for reuse of distribution logic.
2226fn align_content_to_justify(ac: AlignContent) -> JustifyContent {
2227 match ac {
2228 AlignContent::FlexStart | AlignContent::Stretch => JustifyContent::FlexStart,
2229 AlignContent::FlexEnd => JustifyContent::FlexEnd,
2230 AlignContent::Center => JustifyContent::Center,
2231 AlignContent::SpaceBetween => JustifyContent::SpaceBetween,
2232 AlignContent::SpaceAround => JustifyContent::SpaceAround,
2233 }
2234}
2235
2236/// Compute the initial offset and between-item spacing for content distribution.
2237/// Returns (initial_offset, between_spacing).
2238fn compute_content_distribution(
2239 mode: JustifyContent,
2240 free_space: f32,
2241 item_count: usize,
2242) -> (f32, f32) {
2243 if item_count == 0 {
2244 return (0.0, 0.0);
2245 }
2246 match mode {
2247 JustifyContent::FlexStart => (0.0, 0.0),
2248 JustifyContent::FlexEnd => (free_space, 0.0),
2249 JustifyContent::Center => (free_space / 2.0, 0.0),
2250 JustifyContent::SpaceBetween => {
2251 if item_count <= 1 {
2252 (0.0, 0.0)
2253 } else {
2254 (0.0, free_space / (item_count - 1) as f32)
2255 }
2256 }
2257 JustifyContent::SpaceAround => {
2258 let per_item = free_space / item_count as f32;
2259 (per_item / 2.0, per_item)
2260 }
2261 JustifyContent::SpaceEvenly => {
2262 let spacing = free_space / (item_count + 1) as f32;
2263 (spacing, spacing)
2264 }
2265 }
2266}
2267
2268// ---------------------------------------------------------------------------
2269// Inline formatting context
2270// ---------------------------------------------------------------------------
2271
2272/// An inline item produced by flattening the inline tree.
2273enum InlineItemKind {
2274 /// A word of text with associated styling.
2275 Word {
2276 text: String,
2277 font_size: f32,
2278 color: Color,
2279 text_decoration: TextDecoration,
2280 background_color: Color,
2281 },
2282 /// Whitespace between words.
2283 Space { font_size: f32 },
2284 /// Forced line break (`<br>`).
2285 ForcedBreak,
2286 /// Start of an inline box (for margin/padding/border tracking).
2287 InlineStart {
2288 margin_left: f32,
2289 padding_left: f32,
2290 border_left: f32,
2291 },
2292 /// End of an inline box.
2293 InlineEnd {
2294 margin_right: f32,
2295 padding_right: f32,
2296 border_right: f32,
2297 },
2298}
2299
2300/// A pending fragment on the current line.
2301struct PendingFragment {
2302 text: String,
2303 x: f32,
2304 width: f32,
2305 font_size: f32,
2306 color: Color,
2307 text_decoration: TextDecoration,
2308 background_color: Color,
2309}
2310
2311/// Flatten the inline children tree into a sequence of items.
2312fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec<InlineItemKind>) {
2313 for child in children {
2314 // Skip out-of-flow children (absolute/fixed positioned).
2315 if !is_in_flow(child) {
2316 continue;
2317 }
2318 match &child.box_type {
2319 BoxType::TextRun { text, .. } => {
2320 let words = split_into_words(text);
2321 for segment in words {
2322 match segment {
2323 WordSegment::Word(w) => {
2324 items.push(InlineItemKind::Word {
2325 text: w,
2326 font_size: child.font_size,
2327 color: child.color,
2328 text_decoration: child.text_decoration,
2329 background_color: child.background_color,
2330 });
2331 }
2332 WordSegment::Space => {
2333 items.push(InlineItemKind::Space {
2334 font_size: child.font_size,
2335 });
2336 }
2337 }
2338 }
2339 }
2340 BoxType::Inline(node_id) => {
2341 if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) {
2342 if tag_name == "br" {
2343 items.push(InlineItemKind::ForcedBreak);
2344 continue;
2345 }
2346 }
2347
2348 items.push(InlineItemKind::InlineStart {
2349 margin_left: child.margin.left,
2350 padding_left: child.padding.left,
2351 border_left: child.border.left,
2352 });
2353
2354 flatten_inline_tree(&child.children, doc, items);
2355
2356 items.push(InlineItemKind::InlineEnd {
2357 margin_right: child.margin.right,
2358 padding_right: child.padding.right,
2359 border_right: child.border.right,
2360 });
2361 }
2362 _ => {}
2363 }
2364 }
2365}
2366
2367enum WordSegment {
2368 Word(String),
2369 Space,
2370}
2371
2372/// Split text into alternating words and spaces.
2373fn split_into_words(text: &str) -> Vec<WordSegment> {
2374 let mut segments = Vec::new();
2375 let mut current_word = String::new();
2376
2377 for ch in text.chars() {
2378 if ch == ' ' {
2379 if !current_word.is_empty() {
2380 segments.push(WordSegment::Word(std::mem::take(&mut current_word)));
2381 }
2382 segments.push(WordSegment::Space);
2383 } else {
2384 current_word.push(ch);
2385 }
2386 }
2387
2388 if !current_word.is_empty() {
2389 segments.push(WordSegment::Word(current_word));
2390 }
2391
2392 segments
2393}
2394
2395/// Lay out inline children using a proper inline formatting context.
2396///
2397/// If `float_ctx` is provided, line boxes are shortened to avoid overlapping
2398/// with active floats from the parent block formatting context.
2399fn layout_inline_children(
2400 parent: &mut LayoutBox,
2401 font: &Font,
2402 doc: &Document,
2403 float_ctx: Option<&FloatContext>,
2404) {
2405 let available_width = parent.rect.width;
2406 let text_align = parent.text_align;
2407 let line_height = parent.line_height;
2408
2409 let mut items = Vec::new();
2410 flatten_inline_tree(&parent.children, doc, &mut items);
2411
2412 if items.is_empty() {
2413 parent.rect.height = 0.0;
2414 return;
2415 }
2416
2417 // Build line boxes, respecting float-narrowed available widths.
2418 let mut all_lines: Vec<(Vec<PendingFragment>, f32, f32)> = Vec::new(); // (fragments, line_left_offset, line_available_width)
2419 let mut current_line: Vec<PendingFragment> = Vec::new();
2420 let mut cursor_x: f32 = 0.0;
2421 let mut line_y = parent.rect.y;
2422
2423 // Compute the available width for the current line, narrowed by floats.
2424 let line_avail = |y: f32| -> (f32, f32) {
2425 if let Some(fctx) = float_ctx {
2426 let (left, right) =
2427 fctx.available_range(y, line_height, parent.rect.x, available_width);
2428 let offset = (left - parent.rect.x).max(0.0);
2429 let width = (right - left).max(0.0);
2430 (offset, width)
2431 } else {
2432 (0.0, available_width)
2433 }
2434 };
2435
2436 let (mut current_line_offset, mut current_line_width) = line_avail(line_y);
2437
2438 for item in &items {
2439 match item {
2440 InlineItemKind::Word {
2441 text,
2442 font_size,
2443 color,
2444 text_decoration,
2445 background_color,
2446 } => {
2447 let word_width = measure_text_width(font, text, *font_size);
2448
2449 // If this word doesn't fit and the line isn't empty, break.
2450 if cursor_x > 0.0 && cursor_x + word_width > current_line_width {
2451 all_lines.push((
2452 std::mem::take(&mut current_line),
2453 current_line_offset,
2454 current_line_width,
2455 ));
2456 line_y += line_height;
2457 let (off, w) = line_avail(line_y);
2458 current_line_offset = off;
2459 current_line_width = w;
2460 cursor_x = 0.0;
2461 }
2462
2463 current_line.push(PendingFragment {
2464 text: text.clone(),
2465 x: cursor_x,
2466 width: word_width,
2467 font_size: *font_size,
2468 color: *color,
2469 text_decoration: *text_decoration,
2470 background_color: *background_color,
2471 });
2472 cursor_x += word_width;
2473 }
2474 InlineItemKind::Space { font_size } => {
2475 // Only add space if we have content on the line.
2476 if !current_line.is_empty() {
2477 let space_width = measure_text_width(font, " ", *font_size);
2478 if cursor_x + space_width <= current_line_width {
2479 cursor_x += space_width;
2480 }
2481 }
2482 }
2483 InlineItemKind::ForcedBreak => {
2484 all_lines.push((
2485 std::mem::take(&mut current_line),
2486 current_line_offset,
2487 current_line_width,
2488 ));
2489 line_y += line_height;
2490 let (off, w) = line_avail(line_y);
2491 current_line_offset = off;
2492 current_line_width = w;
2493 cursor_x = 0.0;
2494 }
2495 InlineItemKind::InlineStart {
2496 margin_left,
2497 padding_left,
2498 border_left,
2499 } => {
2500 cursor_x += margin_left + padding_left + border_left;
2501 }
2502 InlineItemKind::InlineEnd {
2503 margin_right,
2504 padding_right,
2505 border_right,
2506 } => {
2507 cursor_x += margin_right + padding_right + border_right;
2508 }
2509 }
2510 }
2511
2512 // Flush the last line.
2513 if !current_line.is_empty() {
2514 all_lines.push((current_line, current_line_offset, current_line_width));
2515 }
2516
2517 if all_lines.is_empty() {
2518 parent.rect.height = 0.0;
2519 return;
2520 }
2521
2522 // Position lines vertically and apply text-align.
2523 let mut text_lines = Vec::new();
2524 let mut y = parent.rect.y;
2525 let num_lines = all_lines.len();
2526
2527 for (line_idx, (line_fragments, line_offset, line_avail_w)) in all_lines.iter().enumerate() {
2528 if line_fragments.is_empty() {
2529 y += line_height;
2530 continue;
2531 }
2532
2533 // Compute line width from last fragment.
2534 let line_width = match line_fragments.last() {
2535 Some(last) => last.x + last.width,
2536 None => 0.0,
2537 };
2538
2539 // Compute text-align offset.
2540 let is_last_line = line_idx == num_lines - 1;
2541 let align_offset =
2542 compute_align_offset(text_align, *line_avail_w, line_width, is_last_line);
2543
2544 for frag in line_fragments {
2545 text_lines.push(TextLine {
2546 text: frag.text.clone(),
2547 x: parent.rect.x + line_offset + frag.x + align_offset,
2548 y,
2549 width: frag.width,
2550 font_size: frag.font_size,
2551 color: frag.color,
2552 text_decoration: frag.text_decoration,
2553 background_color: frag.background_color,
2554 });
2555 }
2556
2557 y += line_height;
2558 }
2559
2560 parent.rect.height = num_lines as f32 * line_height;
2561 parent.lines = text_lines;
2562}
2563
2564/// Compute the horizontal offset for text alignment.
2565fn compute_align_offset(
2566 align: TextAlign,
2567 available_width: f32,
2568 line_width: f32,
2569 is_last_line: bool,
2570) -> f32 {
2571 let extra_space = (available_width - line_width).max(0.0);
2572 match align {
2573 TextAlign::Left => 0.0,
2574 TextAlign::Center => extra_space / 2.0,
2575 TextAlign::Right => extra_space,
2576 TextAlign::Justify => {
2577 // Don't justify the last line (CSS spec behavior).
2578 if is_last_line {
2579 0.0
2580 } else {
2581 // For justify, we shift the whole line by 0 — the actual distribution
2582 // of space between words would need per-word spacing. For now, treat
2583 // as left-aligned; full justify support is a future enhancement.
2584 0.0
2585 }
2586 }
2587 }
2588}
2589
2590// ---------------------------------------------------------------------------
2591// Text measurement
2592// ---------------------------------------------------------------------------
2593
2594/// Measure the total advance width of a text string at the given font size.
2595fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 {
2596 let shaped = font.shape_text(text, font_size);
2597 match shaped.last() {
2598 Some(last) => last.x_offset + last.x_advance,
2599 None => 0.0,
2600 }
2601}
2602
2603// ---------------------------------------------------------------------------
2604// Public API
2605// ---------------------------------------------------------------------------
2606
2607/// Build and lay out from a styled tree (produced by `resolve_styles`).
2608///
2609/// Returns a `LayoutTree` with positioned boxes ready for rendering.
2610pub fn layout(
2611 styled_root: &StyledNode,
2612 doc: &Document,
2613 viewport_width: f32,
2614 viewport_height: f32,
2615 font: &Font,
2616 image_sizes: &HashMap<NodeId, (f32, f32)>,
2617) -> LayoutTree {
2618 let mut root = match build_box(styled_root, doc, image_sizes) {
2619 Some(b) => b,
2620 None => {
2621 return LayoutTree {
2622 root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()),
2623 width: viewport_width,
2624 height: 0.0,
2625 };
2626 }
2627 };
2628
2629 // Pre-collapse parent-child margins before positioning.
2630 pre_collapse_margins(&mut root);
2631
2632 // The initial containing block for absolutely positioned elements is the viewport.
2633 let viewport_cb = Rect {
2634 x: 0.0,
2635 y: 0.0,
2636 width: viewport_width,
2637 height: viewport_height,
2638 };
2639 compute_layout(
2640 &mut root,
2641 0.0,
2642 0.0,
2643 viewport_width,
2644 viewport_width,
2645 viewport_height,
2646 font,
2647 doc,
2648 viewport_cb,
2649 None,
2650 );
2651
2652 let height = root.margin_box_height();
2653 LayoutTree {
2654 root,
2655 width: viewport_width,
2656 height,
2657 }
2658}
2659
2660#[cfg(test)]
2661mod tests {
2662 use super::*;
2663 use we_dom::Document;
2664 use we_style::computed::{extract_stylesheets, resolve_styles};
2665
2666 fn test_font() -> Font {
2667 let paths = [
2668 "/System/Library/Fonts/Geneva.ttf",
2669 "/System/Library/Fonts/Monaco.ttf",
2670 ];
2671 for path in &paths {
2672 let p = std::path::Path::new(path);
2673 if p.exists() {
2674 return Font::from_file(p).expect("failed to parse font");
2675 }
2676 }
2677 panic!("no test font found");
2678 }
2679
2680 fn layout_doc(doc: &Document) -> LayoutTree {
2681 let font = test_font();
2682 let sheets = extract_stylesheets(doc);
2683 let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap();
2684 layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new())
2685 }
2686
2687 #[test]
2688 fn empty_document() {
2689 let doc = Document::new();
2690 let font = test_font();
2691 let sheets = extract_stylesheets(&doc);
2692 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0));
2693 if let Some(styled) = styled {
2694 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
2695 assert_eq!(tree.width, 800.0);
2696 }
2697 }
2698
2699 #[test]
2700 fn single_paragraph() {
2701 let mut doc = Document::new();
2702 let root = doc.root();
2703 let html = doc.create_element("html");
2704 let body = doc.create_element("body");
2705 let p = doc.create_element("p");
2706 let text = doc.create_text("Hello world");
2707 doc.append_child(root, html);
2708 doc.append_child(html, body);
2709 doc.append_child(body, p);
2710 doc.append_child(p, text);
2711
2712 let tree = layout_doc(&doc);
2713
2714 assert!(matches!(tree.root.box_type, BoxType::Block(_)));
2715
2716 let body_box = &tree.root.children[0];
2717 assert!(matches!(body_box.box_type, BoxType::Block(_)));
2718
2719 let p_box = &body_box.children[0];
2720 assert!(matches!(p_box.box_type, BoxType::Block(_)));
2721
2722 assert!(!p_box.lines.is_empty(), "p should have text fragments");
2723
2724 // Collect all text on the first visual line.
2725 let first_y = p_box.lines[0].y;
2726 let line_text: String = p_box
2727 .lines
2728 .iter()
2729 .filter(|l| (l.y - first_y).abs() < 0.01)
2730 .map(|l| l.text.as_str())
2731 .collect::<Vec<_>>()
2732 .join(" ");
2733 assert!(
2734 line_text.contains("Hello") && line_text.contains("world"),
2735 "line should contain Hello and world, got: {line_text}"
2736 );
2737
2738 assert_eq!(p_box.margin.top, 16.0);
2739 assert_eq!(p_box.margin.bottom, 16.0);
2740 }
2741
2742 #[test]
2743 fn blocks_stack_vertically() {
2744 let mut doc = Document::new();
2745 let root = doc.root();
2746 let html = doc.create_element("html");
2747 let body = doc.create_element("body");
2748 let p1 = doc.create_element("p");
2749 let t1 = doc.create_text("First");
2750 let p2 = doc.create_element("p");
2751 let t2 = doc.create_text("Second");
2752 doc.append_child(root, html);
2753 doc.append_child(html, body);
2754 doc.append_child(body, p1);
2755 doc.append_child(p1, t1);
2756 doc.append_child(body, p2);
2757 doc.append_child(p2, t2);
2758
2759 let tree = layout_doc(&doc);
2760 let body_box = &tree.root.children[0];
2761 let first = &body_box.children[0];
2762 let second = &body_box.children[1];
2763
2764 assert!(
2765 second.rect.y > first.rect.y,
2766 "second p (y={}) should be below first p (y={})",
2767 second.rect.y,
2768 first.rect.y
2769 );
2770 }
2771
2772 #[test]
2773 fn heading_larger_than_body() {
2774 let mut doc = Document::new();
2775 let root = doc.root();
2776 let html = doc.create_element("html");
2777 let body = doc.create_element("body");
2778 let h1 = doc.create_element("h1");
2779 let h1_text = doc.create_text("Title");
2780 let p = doc.create_element("p");
2781 let p_text = doc.create_text("Text");
2782 doc.append_child(root, html);
2783 doc.append_child(html, body);
2784 doc.append_child(body, h1);
2785 doc.append_child(h1, h1_text);
2786 doc.append_child(body, p);
2787 doc.append_child(p, p_text);
2788
2789 let tree = layout_doc(&doc);
2790 let body_box = &tree.root.children[0];
2791 let h1_box = &body_box.children[0];
2792 let p_box = &body_box.children[1];
2793
2794 assert!(
2795 h1_box.font_size > p_box.font_size,
2796 "h1 font_size ({}) should be > p font_size ({})",
2797 h1_box.font_size,
2798 p_box.font_size
2799 );
2800 assert_eq!(h1_box.font_size, 32.0);
2801
2802 assert!(
2803 h1_box.rect.height > p_box.rect.height,
2804 "h1 height ({}) should be > p height ({})",
2805 h1_box.rect.height,
2806 p_box.rect.height
2807 );
2808 }
2809
2810 #[test]
2811 fn body_has_default_margin() {
2812 let mut doc = Document::new();
2813 let root = doc.root();
2814 let html = doc.create_element("html");
2815 let body = doc.create_element("body");
2816 let p = doc.create_element("p");
2817 let text = doc.create_text("Test");
2818 doc.append_child(root, html);
2819 doc.append_child(html, body);
2820 doc.append_child(body, p);
2821 doc.append_child(p, text);
2822
2823 let tree = layout_doc(&doc);
2824 let body_box = &tree.root.children[0];
2825
2826 // body default margin is 8px, but it collapses with p's 16px margin
2827 // (parent-child collapsing: no border/padding on body).
2828 assert_eq!(body_box.margin.top, 16.0);
2829 assert_eq!(body_box.margin.right, 8.0);
2830 assert_eq!(body_box.margin.bottom, 16.0);
2831 assert_eq!(body_box.margin.left, 8.0);
2832
2833 assert_eq!(body_box.rect.x, 8.0);
2834 // body.rect.y = collapsed margin (16) from viewport top.
2835 assert_eq!(body_box.rect.y, 16.0);
2836 }
2837
2838 #[test]
2839 fn text_wraps_at_container_width() {
2840 let mut doc = Document::new();
2841 let root = doc.root();
2842 let html = doc.create_element("html");
2843 let body = doc.create_element("body");
2844 let p = doc.create_element("p");
2845 let text =
2846 doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap");
2847 doc.append_child(root, html);
2848 doc.append_child(html, body);
2849 doc.append_child(body, p);
2850 doc.append_child(p, text);
2851
2852 let font = test_font();
2853 let sheets = extract_stylesheets(&doc);
2854 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
2855 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new());
2856 let body_box = &tree.root.children[0];
2857 let p_box = &body_box.children[0];
2858
2859 // Count distinct y-positions to count visual lines.
2860 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect();
2861 ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
2862 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01);
2863
2864 assert!(
2865 ys.len() > 1,
2866 "text should wrap to multiple lines, got {} visual lines",
2867 ys.len()
2868 );
2869 }
2870
2871 #[test]
2872 fn layout_produces_positive_dimensions() {
2873 let mut doc = Document::new();
2874 let root = doc.root();
2875 let html = doc.create_element("html");
2876 let body = doc.create_element("body");
2877 let div = doc.create_element("div");
2878 let text = doc.create_text("Content");
2879 doc.append_child(root, html);
2880 doc.append_child(html, body);
2881 doc.append_child(body, div);
2882 doc.append_child(div, text);
2883
2884 let tree = layout_doc(&doc);
2885
2886 for b in tree.iter() {
2887 assert!(b.rect.width >= 0.0, "width should be >= 0");
2888 assert!(b.rect.height >= 0.0, "height should be >= 0");
2889 }
2890
2891 assert!(tree.height > 0.0, "layout height should be > 0");
2892 }
2893
2894 #[test]
2895 fn head_is_hidden() {
2896 let mut doc = Document::new();
2897 let root = doc.root();
2898 let html = doc.create_element("html");
2899 let head = doc.create_element("head");
2900 let title = doc.create_element("title");
2901 let title_text = doc.create_text("Page Title");
2902 let body = doc.create_element("body");
2903 let p = doc.create_element("p");
2904 let p_text = doc.create_text("Visible");
2905 doc.append_child(root, html);
2906 doc.append_child(html, head);
2907 doc.append_child(head, title);
2908 doc.append_child(title, title_text);
2909 doc.append_child(html, body);
2910 doc.append_child(body, p);
2911 doc.append_child(p, p_text);
2912
2913 let tree = layout_doc(&doc);
2914
2915 assert_eq!(
2916 tree.root.children.len(),
2917 1,
2918 "html should have 1 child (body), head should be hidden"
2919 );
2920 }
2921
2922 #[test]
2923 fn mixed_block_and_inline() {
2924 let mut doc = Document::new();
2925 let root = doc.root();
2926 let html = doc.create_element("html");
2927 let body = doc.create_element("body");
2928 let div = doc.create_element("div");
2929 let text1 = doc.create_text("Text");
2930 let p = doc.create_element("p");
2931 let p_text = doc.create_text("Block");
2932 let text2 = doc.create_text("More");
2933 doc.append_child(root, html);
2934 doc.append_child(html, body);
2935 doc.append_child(body, div);
2936 doc.append_child(div, text1);
2937 doc.append_child(div, p);
2938 doc.append_child(p, p_text);
2939 doc.append_child(div, text2);
2940
2941 let tree = layout_doc(&doc);
2942 let body_box = &tree.root.children[0];
2943 let div_box = &body_box.children[0];
2944
2945 assert_eq!(
2946 div_box.children.len(),
2947 3,
2948 "div should have 3 children (anon, block, anon), got {}",
2949 div_box.children.len()
2950 );
2951
2952 assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous));
2953 assert!(matches!(div_box.children[1].box_type, BoxType::Block(_)));
2954 assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous));
2955 }
2956
2957 #[test]
2958 fn inline_elements_contribute_text() {
2959 let mut doc = Document::new();
2960 let root = doc.root();
2961 let html = doc.create_element("html");
2962 let body = doc.create_element("body");
2963 let p = doc.create_element("p");
2964 let t1 = doc.create_text("Hello ");
2965 let em = doc.create_element("em");
2966 let t2 = doc.create_text("world");
2967 let t3 = doc.create_text("!");
2968 doc.append_child(root, html);
2969 doc.append_child(html, body);
2970 doc.append_child(body, p);
2971 doc.append_child(p, t1);
2972 doc.append_child(p, em);
2973 doc.append_child(em, t2);
2974 doc.append_child(p, t3);
2975
2976 let tree = layout_doc(&doc);
2977 let body_box = &tree.root.children[0];
2978 let p_box = &body_box.children[0];
2979
2980 assert!(!p_box.lines.is_empty());
2981
2982 let first_y = p_box.lines[0].y;
2983 let line_texts: Vec<&str> = p_box
2984 .lines
2985 .iter()
2986 .filter(|l| (l.y - first_y).abs() < 0.01)
2987 .map(|l| l.text.as_str())
2988 .collect();
2989 let combined = line_texts.join("");
2990 assert!(
2991 combined.contains("Hello") && combined.contains("world") && combined.contains("!"),
2992 "line should contain all text, got: {combined}"
2993 );
2994 }
2995
2996 #[test]
2997 fn collapse_whitespace_works() {
2998 assert_eq!(collapse_whitespace("hello world"), "hello world");
2999 assert_eq!(collapse_whitespace(" spaces "), " spaces ");
3000 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs ");
3001 assert_eq!(collapse_whitespace("no-extra"), "no-extra");
3002 assert_eq!(collapse_whitespace(" "), " ");
3003 }
3004
3005 #[test]
3006 fn content_width_respects_body_margin() {
3007 let mut doc = Document::new();
3008 let root = doc.root();
3009 let html = doc.create_element("html");
3010 let body = doc.create_element("body");
3011 let div = doc.create_element("div");
3012 let text = doc.create_text("Content");
3013 doc.append_child(root, html);
3014 doc.append_child(html, body);
3015 doc.append_child(body, div);
3016 doc.append_child(div, text);
3017
3018 let font = test_font();
3019 let sheets = extract_stylesheets(&doc);
3020 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3021 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3022 let body_box = &tree.root.children[0];
3023
3024 assert_eq!(body_box.rect.width, 784.0);
3025
3026 let div_box = &body_box.children[0];
3027 assert_eq!(div_box.rect.width, 784.0);
3028 }
3029
3030 #[test]
3031 fn multiple_heading_levels() {
3032 let mut doc = Document::new();
3033 let root = doc.root();
3034 let html = doc.create_element("html");
3035 let body = doc.create_element("body");
3036 doc.append_child(root, html);
3037 doc.append_child(html, body);
3038
3039 let tags = ["h1", "h2", "h3"];
3040 for tag in &tags {
3041 let h = doc.create_element(tag);
3042 let t = doc.create_text(tag);
3043 doc.append_child(body, h);
3044 doc.append_child(h, t);
3045 }
3046
3047 let tree = layout_doc(&doc);
3048 let body_box = &tree.root.children[0];
3049
3050 let h1 = &body_box.children[0];
3051 let h2 = &body_box.children[1];
3052 let h3 = &body_box.children[2];
3053 assert!(h1.font_size > h2.font_size);
3054 assert!(h2.font_size > h3.font_size);
3055
3056 assert!(h2.rect.y > h1.rect.y);
3057 assert!(h3.rect.y > h2.rect.y);
3058 }
3059
3060 #[test]
3061 fn layout_tree_iteration() {
3062 let mut doc = Document::new();
3063 let root = doc.root();
3064 let html = doc.create_element("html");
3065 let body = doc.create_element("body");
3066 let p = doc.create_element("p");
3067 let text = doc.create_text("Test");
3068 doc.append_child(root, html);
3069 doc.append_child(html, body);
3070 doc.append_child(body, p);
3071 doc.append_child(p, text);
3072
3073 let tree = layout_doc(&doc);
3074 let count = tree.iter().count();
3075 assert!(count >= 3, "should have at least html, body, p boxes");
3076 }
3077
3078 #[test]
3079 fn css_style_affects_layout() {
3080 let html_str = r#"<!DOCTYPE html>
3081<html>
3082<head>
3083<style>
3084p { margin-top: 50px; margin-bottom: 50px; }
3085</style>
3086</head>
3087<body>
3088<p>First</p>
3089<p>Second</p>
3090</body>
3091</html>"#;
3092 let doc = we_html::parse_html(html_str);
3093 let font = test_font();
3094 let sheets = extract_stylesheets(&doc);
3095 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3096 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3097
3098 let body_box = &tree.root.children[0];
3099 let first = &body_box.children[0];
3100 let second = &body_box.children[1];
3101
3102 assert_eq!(first.margin.top, 50.0);
3103 assert_eq!(first.margin.bottom, 50.0);
3104
3105 // Adjacent sibling margins collapse: gap = max(50, 50) = 50, not 100.
3106 let gap = second.rect.y - (first.rect.y + first.rect.height);
3107 assert!(
3108 (gap - 50.0).abs() < 1.0,
3109 "collapsed margin gap should be ~50px, got {gap}"
3110 );
3111 }
3112
3113 #[test]
3114 fn inline_style_affects_layout() {
3115 let html_str = r#"<!DOCTYPE html>
3116<html>
3117<body>
3118<div style="padding-top: 20px; padding-bottom: 20px;">
3119<p>Content</p>
3120</div>
3121</body>
3122</html>"#;
3123 let doc = we_html::parse_html(html_str);
3124 let font = test_font();
3125 let sheets = extract_stylesheets(&doc);
3126 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3127 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3128
3129 let body_box = &tree.root.children[0];
3130 let div_box = &body_box.children[0];
3131
3132 assert_eq!(div_box.padding.top, 20.0);
3133 assert_eq!(div_box.padding.bottom, 20.0);
3134 }
3135
3136 #[test]
3137 fn css_color_propagates_to_layout() {
3138 let html_str = r#"<!DOCTYPE html>
3139<html>
3140<head><style>p { color: red; background-color: blue; }</style></head>
3141<body><p>Colored</p></body>
3142</html>"#;
3143 let doc = we_html::parse_html(html_str);
3144 let font = test_font();
3145 let sheets = extract_stylesheets(&doc);
3146 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3147 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3148
3149 let body_box = &tree.root.children[0];
3150 let p_box = &body_box.children[0];
3151
3152 assert_eq!(p_box.color, Color::rgb(255, 0, 0));
3153 assert_eq!(p_box.background_color, Color::rgb(0, 0, 255));
3154 }
3155
3156 // --- New inline layout tests ---
3157
3158 #[test]
3159 fn inline_elements_have_per_fragment_styling() {
3160 let html_str = r#"<!DOCTYPE html>
3161<html>
3162<head><style>em { color: red; }</style></head>
3163<body><p>Hello <em>world</em></p></body>
3164</html>"#;
3165 let doc = we_html::parse_html(html_str);
3166 let font = test_font();
3167 let sheets = extract_stylesheets(&doc);
3168 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3169 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3170
3171 let body_box = &tree.root.children[0];
3172 let p_box = &body_box.children[0];
3173
3174 let colors: Vec<Color> = p_box.lines.iter().map(|l| l.color).collect();
3175 assert!(
3176 colors.iter().any(|c| *c == Color::rgb(0, 0, 0)),
3177 "should have black text"
3178 );
3179 assert!(
3180 colors.iter().any(|c| *c == Color::rgb(255, 0, 0)),
3181 "should have red text from <em>"
3182 );
3183 }
3184
3185 #[test]
3186 fn br_element_forces_line_break() {
3187 let html_str = r#"<!DOCTYPE html>
3188<html>
3189<body><p>Line one<br>Line two</p></body>
3190</html>"#;
3191 let doc = we_html::parse_html(html_str);
3192 let font = test_font();
3193 let sheets = extract_stylesheets(&doc);
3194 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3195 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3196
3197 let body_box = &tree.root.children[0];
3198 let p_box = &body_box.children[0];
3199
3200 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect();
3201 ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
3202 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01);
3203
3204 assert!(
3205 ys.len() >= 2,
3206 "<br> should produce 2 visual lines, got {}",
3207 ys.len()
3208 );
3209 }
3210
3211 #[test]
3212 fn text_align_center() {
3213 let html_str = r#"<!DOCTYPE html>
3214<html>
3215<head><style>p { text-align: center; }</style></head>
3216<body><p>Hi</p></body>
3217</html>"#;
3218 let doc = we_html::parse_html(html_str);
3219 let font = test_font();
3220 let sheets = extract_stylesheets(&doc);
3221 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3222 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3223
3224 let body_box = &tree.root.children[0];
3225 let p_box = &body_box.children[0];
3226
3227 assert!(!p_box.lines.is_empty());
3228 let first = &p_box.lines[0];
3229 // Center-aligned: text should be noticeably offset from content x.
3230 assert!(
3231 first.x > p_box.rect.x + 10.0,
3232 "center-aligned text x ({}) should be offset from content x ({})",
3233 first.x,
3234 p_box.rect.x
3235 );
3236 }
3237
3238 #[test]
3239 fn text_align_right() {
3240 let html_str = r#"<!DOCTYPE html>
3241<html>
3242<head><style>p { text-align: right; }</style></head>
3243<body><p>Hi</p></body>
3244</html>"#;
3245 let doc = we_html::parse_html(html_str);
3246 let font = test_font();
3247 let sheets = extract_stylesheets(&doc);
3248 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3249 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3250
3251 let body_box = &tree.root.children[0];
3252 let p_box = &body_box.children[0];
3253
3254 assert!(!p_box.lines.is_empty());
3255 let first = &p_box.lines[0];
3256 let right_edge = p_box.rect.x + p_box.rect.width;
3257 assert!(
3258 (first.x + first.width - right_edge).abs() < 1.0,
3259 "right-aligned text end ({}) should be near right edge ({})",
3260 first.x + first.width,
3261 right_edge
3262 );
3263 }
3264
3265 #[test]
3266 fn inline_padding_offsets_text() {
3267 let html_str = r#"<!DOCTYPE html>
3268<html>
3269<head><style>span { padding-left: 20px; padding-right: 20px; }</style></head>
3270<body><p>A<span>B</span>C</p></body>
3271</html>"#;
3272 let doc = we_html::parse_html(html_str);
3273 let font = test_font();
3274 let sheets = extract_stylesheets(&doc);
3275 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3276 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3277
3278 let body_box = &tree.root.children[0];
3279 let p_box = &body_box.children[0];
3280
3281 // Should have at least 3 fragments: A, B, C
3282 assert!(
3283 p_box.lines.len() >= 3,
3284 "should have fragments for A, B, C, got {}",
3285 p_box.lines.len()
3286 );
3287
3288 // B should be offset by the span's padding.
3289 let a_frag = &p_box.lines[0];
3290 let b_frag = &p_box.lines[1];
3291 let gap = b_frag.x - (a_frag.x + a_frag.width);
3292 // Gap should include the 20px padding-left from the span.
3293 assert!(
3294 gap >= 19.0,
3295 "gap between A and B ({gap}) should include span padding-left (20px)"
3296 );
3297 }
3298
3299 #[test]
3300 fn text_fragments_have_correct_font_size() {
3301 let html_str = r#"<!DOCTYPE html>
3302<html>
3303<body><h1>Big</h1><p>Small</p></body>
3304</html>"#;
3305 let doc = we_html::parse_html(html_str);
3306 let font = test_font();
3307 let sheets = extract_stylesheets(&doc);
3308 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3309 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3310
3311 let body_box = &tree.root.children[0];
3312 let h1_box = &body_box.children[0];
3313 let p_box = &body_box.children[1];
3314
3315 assert!(!h1_box.lines.is_empty());
3316 assert!(!p_box.lines.is_empty());
3317 assert_eq!(h1_box.lines[0].font_size, 32.0);
3318 assert_eq!(p_box.lines[0].font_size, 16.0);
3319 }
3320
3321 #[test]
3322 fn line_height_from_computed_style() {
3323 let html_str = r#"<!DOCTYPE html>
3324<html>
3325<head><style>p { line-height: 30px; }</style></head>
3326<body><p>Line one Line two Line three</p></body>
3327</html>"#;
3328 let doc = we_html::parse_html(html_str);
3329 let font = test_font();
3330 let sheets = extract_stylesheets(&doc);
3331 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3332 // Narrow viewport to force wrapping.
3333 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new());
3334
3335 let body_box = &tree.root.children[0];
3336 let p_box = &body_box.children[0];
3337
3338 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect();
3339 ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
3340 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01);
3341
3342 if ys.len() >= 2 {
3343 let gap = ys[1] - ys[0];
3344 assert!(
3345 (gap - 30.0).abs() < 1.0,
3346 "line spacing ({gap}) should be ~30px from line-height"
3347 );
3348 }
3349 }
3350
3351 // --- Relative positioning tests ---
3352
3353 #[test]
3354 fn relative_position_top_left() {
3355 let html_str = r#"<!DOCTYPE html>
3356<html>
3357<body>
3358<div style="position: relative; top: 10px; left: 20px;">Content</div>
3359</body>
3360</html>"#;
3361 let doc = we_html::parse_html(html_str);
3362 let font = test_font();
3363 let sheets = extract_stylesheets(&doc);
3364 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3365 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3366
3367 let body_box = &tree.root.children[0];
3368 let div_box = &body_box.children[0];
3369
3370 assert_eq!(div_box.position, Position::Relative);
3371 assert_eq!(div_box.relative_offset, (20.0, 10.0));
3372
3373 // The div should be shifted from where it would be in normal flow.
3374 // Normal flow position: body.rect.x + margin, body.rect.y + margin.
3375 // With relative offset: shifted by (20, 10).
3376 // Body has 8px margin by default, so content starts at x=8, y=8.
3377 assert!(
3378 (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01,
3379 "div x ({}) should be 28.0 (8 + 20)",
3380 div_box.rect.x
3381 );
3382 assert!(
3383 (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01,
3384 "div y ({}) should be 18.0 (8 + 10)",
3385 div_box.rect.y
3386 );
3387 }
3388
3389 #[test]
3390 fn relative_position_does_not_affect_siblings() {
3391 let html_str = r#"<!DOCTYPE html>
3392<html>
3393<head><style>
3394p { margin: 0; }
3395</style></head>
3396<body>
3397<p id="first" style="position: relative; top: 50px;">First</p>
3398<p id="second">Second</p>
3399</body>
3400</html>"#;
3401 let doc = we_html::parse_html(html_str);
3402 let font = test_font();
3403 let sheets = extract_stylesheets(&doc);
3404 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3405 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3406
3407 let body_box = &tree.root.children[0];
3408 let first = &body_box.children[0];
3409 let second = &body_box.children[1];
3410
3411 // The first paragraph is shifted down by 50px visually.
3412 assert_eq!(first.relative_offset, (0.0, 50.0));
3413
3414 // But the second paragraph should be at its normal-flow position,
3415 // as if the first paragraph were NOT shifted. The second paragraph
3416 // should come right after the first's normal-flow height.
3417 // Body content starts at y=8 (default body margin). First p has 0 margin.
3418 // Second p should start right after first p's height (without offset).
3419 let first_normal_y = 8.0; // body margin
3420 let first_height = first.rect.height;
3421 let expected_second_y = first_normal_y + first_height;
3422 assert!(
3423 (second.rect.y - expected_second_y).abs() < 1.0,
3424 "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset",
3425 second.rect.y
3426 );
3427 }
3428
3429 #[test]
3430 fn relative_position_conflicting_offsets() {
3431 // When both top and bottom are specified, top wins.
3432 // When both left and right are specified, left wins.
3433 let html_str = r#"<!DOCTYPE html>
3434<html>
3435<body>
3436<div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div>
3437</body>
3438</html>"#;
3439 let doc = we_html::parse_html(html_str);
3440 let font = test_font();
3441 let sheets = extract_stylesheets(&doc);
3442 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3443 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3444
3445 let body_box = &tree.root.children[0];
3446 let div_box = &body_box.children[0];
3447
3448 // top wins over bottom: dy = 10 (not -20)
3449 // left wins over right: dx = 30 (not -40)
3450 assert_eq!(div_box.relative_offset, (30.0, 10.0));
3451 }
3452
3453 #[test]
3454 fn relative_position_auto_offsets() {
3455 // auto offsets should resolve to 0 (no movement).
3456 let html_str = r#"<!DOCTYPE html>
3457<html>
3458<body>
3459<div style="position: relative;">Content</div>
3460</body>
3461</html>"#;
3462 let doc = we_html::parse_html(html_str);
3463 let font = test_font();
3464 let sheets = extract_stylesheets(&doc);
3465 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3466 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3467
3468 let body_box = &tree.root.children[0];
3469 let div_box = &body_box.children[0];
3470
3471 assert_eq!(div_box.position, Position::Relative);
3472 assert_eq!(div_box.relative_offset, (0.0, 0.0));
3473 }
3474
3475 #[test]
3476 fn relative_position_bottom_right() {
3477 // bottom: 15px should shift up by 15px (negative direction).
3478 // right: 25px should shift left by 25px (negative direction).
3479 let html_str = r#"<!DOCTYPE html>
3480<html>
3481<body>
3482<div style="position: relative; bottom: 15px; right: 25px;">Content</div>
3483</body>
3484</html>"#;
3485 let doc = we_html::parse_html(html_str);
3486 let font = test_font();
3487 let sheets = extract_stylesheets(&doc);
3488 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3489 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3490
3491 let body_box = &tree.root.children[0];
3492 let div_box = &body_box.children[0];
3493
3494 assert_eq!(div_box.relative_offset, (-25.0, -15.0));
3495 }
3496
3497 #[test]
3498 fn relative_position_shifts_text_lines() {
3499 let html_str = r#"<!DOCTYPE html>
3500<html>
3501<head><style>p { margin: 0; }</style></head>
3502<body>
3503<p style="position: relative; top: 30px; left: 40px;">Hello</p>
3504</body>
3505</html>"#;
3506 let doc = we_html::parse_html(html_str);
3507 let font = test_font();
3508 let sheets = extract_stylesheets(&doc);
3509 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3510 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3511
3512 let body_box = &tree.root.children[0];
3513 let p_box = &body_box.children[0];
3514
3515 assert!(!p_box.lines.is_empty(), "p should have text lines");
3516 let first_line = &p_box.lines[0];
3517
3518 // Text should be shifted by the relative offset.
3519 // Body content starts at x=8, y=8. With offset: x=48, y=38.
3520 assert!(
3521 first_line.x >= 8.0 + 40.0 - 1.0,
3522 "text x ({}) should be shifted by left offset",
3523 first_line.x
3524 );
3525 assert!(
3526 first_line.y >= 8.0 + 30.0 - 1.0,
3527 "text y ({}) should be shifted by top offset",
3528 first_line.y
3529 );
3530 }
3531
3532 #[test]
3533 fn static_position_has_no_offset() {
3534 let html_str = r#"<!DOCTYPE html>
3535<html>
3536<body>
3537<div>Normal flow</div>
3538</body>
3539</html>"#;
3540 let doc = we_html::parse_html(html_str);
3541 let font = test_font();
3542 let sheets = extract_stylesheets(&doc);
3543 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3544 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3545
3546 let body_box = &tree.root.children[0];
3547 let div_box = &body_box.children[0];
3548
3549 assert_eq!(div_box.position, Position::Static);
3550 assert_eq!(div_box.relative_offset, (0.0, 0.0));
3551 }
3552
3553 // --- Margin collapsing tests ---
3554
3555 #[test]
3556 fn adjacent_sibling_margins_collapse() {
3557 // Two <p> elements each with margin 16px: gap should be 16px (max), not 32px (sum).
3558 let html_str = r#"<!DOCTYPE html>
3559<html>
3560<head><style>
3561body { margin: 0; border-top: 1px solid black; }
3562p { margin-top: 16px; margin-bottom: 16px; }
3563</style></head>
3564<body>
3565<p>First</p>
3566<p>Second</p>
3567</body>
3568</html>"#;
3569 let doc = we_html::parse_html(html_str);
3570 let font = test_font();
3571 let sheets = extract_stylesheets(&doc);
3572 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3573 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3574
3575 let body_box = &tree.root.children[0];
3576 let first = &body_box.children[0];
3577 let second = &body_box.children[1];
3578
3579 // Gap between first's bottom border-box and second's top border-box
3580 // should be the collapsed margin: max(16, 16) = 16.
3581 let first_bottom =
3582 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom;
3583 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom;
3584 assert!(
3585 (gap - 16.0).abs() < 1.0,
3586 "collapsed sibling margin should be ~16px, got {gap}"
3587 );
3588 }
3589
3590 #[test]
3591 fn sibling_margins_collapse_unequal() {
3592 // p1 bottom-margin 20, p2 top-margin 30: gap should be 30 (max).
3593 let html_str = r#"<!DOCTYPE html>
3594<html>
3595<head><style>
3596body { margin: 0; border-top: 1px solid black; }
3597.first { margin-top: 0; margin-bottom: 20px; }
3598.second { margin-top: 30px; margin-bottom: 0; }
3599</style></head>
3600<body>
3601<p class="first">First</p>
3602<p class="second">Second</p>
3603</body>
3604</html>"#;
3605 let doc = we_html::parse_html(html_str);
3606 let font = test_font();
3607 let sheets = extract_stylesheets(&doc);
3608 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3609 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3610
3611 let body_box = &tree.root.children[0];
3612 let first = &body_box.children[0];
3613 let second = &body_box.children[1];
3614
3615 let first_bottom =
3616 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom;
3617 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom;
3618 assert!(
3619 (gap - 30.0).abs() < 1.0,
3620 "collapsed margin should be max(20, 30) = 30, got {gap}"
3621 );
3622 }
3623
3624 #[test]
3625 fn parent_first_child_margin_collapsing() {
3626 // Parent with no padding/border: first child's top margin collapses.
3627 let html_str = r#"<!DOCTYPE html>
3628<html>
3629<head><style>
3630body { margin: 0; border-top: 1px solid black; }
3631.parent { margin-top: 10px; }
3632.child { margin-top: 20px; }
3633</style></head>
3634<body>
3635<div class="parent"><p class="child">Child</p></div>
3636</body>
3637</html>"#;
3638 let doc = we_html::parse_html(html_str);
3639 let font = test_font();
3640 let sheets = extract_stylesheets(&doc);
3641 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3642 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3643
3644 let body_box = &tree.root.children[0];
3645 let parent_box = &body_box.children[0];
3646
3647 // Parent margin collapses with child's: max(10, 20) = 20.
3648 assert_eq!(parent_box.margin.top, 20.0);
3649 }
3650
3651 #[test]
3652 fn negative_margin_collapsing() {
3653 // One positive (20) and one negative (-10): collapsed = 20 + (-10) = 10.
3654 let html_str = r#"<!DOCTYPE html>
3655<html>
3656<head><style>
3657body { margin: 0; border-top: 1px solid black; }
3658.first { margin-top: 0; margin-bottom: 20px; }
3659.second { margin-top: -10px; margin-bottom: 0; }
3660</style></head>
3661<body>
3662<p class="first">First</p>
3663<p class="second">Second</p>
3664</body>
3665</html>"#;
3666 let doc = we_html::parse_html(html_str);
3667 let font = test_font();
3668 let sheets = extract_stylesheets(&doc);
3669 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3670 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3671
3672 let body_box = &tree.root.children[0];
3673 let first = &body_box.children[0];
3674 let second = &body_box.children[1];
3675
3676 let first_bottom =
3677 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom;
3678 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom;
3679 // 20 + (-10) = 10
3680 assert!(
3681 (gap - 10.0).abs() < 1.0,
3682 "positive + negative margin collapse should be 10, got {gap}"
3683 );
3684 }
3685
3686 #[test]
3687 fn both_negative_margins_collapse() {
3688 // Both negative: use the more negative value.
3689 let html_str = r#"<!DOCTYPE html>
3690<html>
3691<head><style>
3692body { margin: 0; border-top: 1px solid black; }
3693.first { margin-top: 0; margin-bottom: -10px; }
3694.second { margin-top: -20px; margin-bottom: 0; }
3695</style></head>
3696<body>
3697<p class="first">First</p>
3698<p class="second">Second</p>
3699</body>
3700</html>"#;
3701 let doc = we_html::parse_html(html_str);
3702 let font = test_font();
3703 let sheets = extract_stylesheets(&doc);
3704 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3705 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3706
3707 let body_box = &tree.root.children[0];
3708 let first = &body_box.children[0];
3709 let second = &body_box.children[1];
3710
3711 let first_bottom =
3712 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom;
3713 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom;
3714 // Both negative: min(-10, -20) = -20
3715 assert!(
3716 (gap - (-20.0)).abs() < 1.0,
3717 "both-negative margin collapse should be -20, got {gap}"
3718 );
3719 }
3720
3721 #[test]
3722 fn border_blocks_margin_collapsing() {
3723 // When border separates margins, they don't collapse.
3724 let html_str = r#"<!DOCTYPE html>
3725<html>
3726<head><style>
3727body { margin: 0; border-top: 1px solid black; }
3728.first { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid black; }
3729.second { margin-top: 20px; border-top: 1px solid black; }
3730</style></head>
3731<body>
3732<p class="first">First</p>
3733<p class="second">Second</p>
3734</body>
3735</html>"#;
3736 let doc = we_html::parse_html(html_str);
3737 let font = test_font();
3738 let sheets = extract_stylesheets(&doc);
3739 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3740 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3741
3742 let body_box = &tree.root.children[0];
3743 let first = &body_box.children[0];
3744 let second = &body_box.children[1];
3745
3746 // Borders are on the elements themselves, but the MARGINS are still
3747 // between the border boxes — sibling margins still collapse regardless
3748 // of borders on the elements. The margin gap = max(20, 20) = 20.
3749 let first_bottom =
3750 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom;
3751 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom;
3752 assert!(
3753 (gap - 20.0).abs() < 1.0,
3754 "sibling margins collapse even with borders on elements, gap should be 20, got {gap}"
3755 );
3756 }
3757
3758 #[test]
3759 fn padding_blocks_parent_child_collapsing() {
3760 // Parent with padding-top prevents margin collapsing with first child.
3761 let html_str = r#"<!DOCTYPE html>
3762<html>
3763<head><style>
3764body { margin: 0; border-top: 1px solid black; }
3765.parent { margin-top: 10px; padding-top: 5px; }
3766.child { margin-top: 20px; }
3767</style></head>
3768<body>
3769<div class="parent"><p class="child">Child</p></div>
3770</body>
3771</html>"#;
3772 let doc = we_html::parse_html(html_str);
3773 let font = test_font();
3774 let sheets = extract_stylesheets(&doc);
3775 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3776 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3777
3778 let body_box = &tree.root.children[0];
3779 let parent_box = &body_box.children[0];
3780
3781 // Parent has padding-top, so no collapsing: margin stays at 10.
3782 assert_eq!(parent_box.margin.top, 10.0);
3783 }
3784
3785 #[test]
3786 fn empty_block_margins_collapse() {
3787 // An empty div's top and bottom margins collapse with adjacent margins.
3788 let html_str = r#"<!DOCTYPE html>
3789<html>
3790<head><style>
3791body { margin: 0; border-top: 1px solid black; }
3792.spacer { margin-top: 10px; margin-bottom: 10px; }
3793p { margin-top: 5px; margin-bottom: 5px; }
3794</style></head>
3795<body>
3796<p>Before</p>
3797<div class="spacer"></div>
3798<p>After</p>
3799</body>
3800</html>"#;
3801 let doc = we_html::parse_html(html_str);
3802 let font = test_font();
3803 let sheets = extract_stylesheets(&doc);
3804 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3805 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3806
3807 let body_box = &tree.root.children[0];
3808 let before = &body_box.children[0];
3809 let after = &body_box.children[2]; // [0]=p, [1]=empty div, [2]=p
3810
3811 // Empty div's margins (10+10) self-collapse to max(10,10)=10.
3812 // Then collapse with before's bottom (5) and after's top (5):
3813 // collapse(5, collapse(10, 10)) = collapse(5, 10) = 10
3814 // Then collapse(10, 5) = 10.
3815 // So total gap between before and after = 10.
3816 let before_bottom =
3817 before.rect.y + before.rect.height + before.padding.bottom + before.border.bottom;
3818 let gap = after.rect.y - after.border.top - after.padding.top - before_bottom;
3819 assert!(
3820 (gap - 10.0).abs() < 1.0,
3821 "empty block margin collapse gap should be ~10px, got {gap}"
3822 );
3823 }
3824
3825 #[test]
3826 fn collapse_margins_unit() {
3827 // Unit tests for the collapse_margins helper.
3828 assert_eq!(collapse_margins(10.0, 20.0), 20.0);
3829 assert_eq!(collapse_margins(20.0, 10.0), 20.0);
3830 assert_eq!(collapse_margins(0.0, 15.0), 15.0);
3831 assert_eq!(collapse_margins(-5.0, -10.0), -10.0);
3832 assert_eq!(collapse_margins(20.0, -5.0), 15.0);
3833 assert_eq!(collapse_margins(-5.0, 20.0), 15.0);
3834 assert_eq!(collapse_margins(0.0, 0.0), 0.0);
3835 }
3836
3837 // --- Box-sizing tests ---
3838
3839 #[test]
3840 fn content_box_default_width_applies_to_content() {
3841 // Default box-sizing (content-box): width = content width only.
3842 let html_str = r#"<!DOCTYPE html>
3843<html>
3844<head><style>
3845body { margin: 0; }
3846div { width: 200px; padding: 10px; border: 5px solid black; }
3847</style></head>
3848<body>
3849<div>Content</div>
3850</body>
3851</html>"#;
3852 let doc = we_html::parse_html(html_str);
3853 let font = test_font();
3854 let sheets = extract_stylesheets(&doc);
3855 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3856 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3857
3858 let body_box = &tree.root.children[0];
3859 let div_box = &body_box.children[0];
3860
3861 // content-box: rect.width = 200 (the specified width IS the content)
3862 assert_eq!(div_box.rect.width, 200.0);
3863 assert_eq!(div_box.padding.left, 10.0);
3864 assert_eq!(div_box.padding.right, 10.0);
3865 assert_eq!(div_box.border.left, 5.0);
3866 assert_eq!(div_box.border.right, 5.0);
3867 }
3868
3869 #[test]
3870 fn border_box_width_includes_padding_and_border() {
3871 // box-sizing: border-box: width includes padding and border.
3872 let html_str = r#"<!DOCTYPE html>
3873<html>
3874<head><style>
3875body { margin: 0; }
3876div { box-sizing: border-box; width: 200px; padding: 10px; border: 5px solid black; }
3877</style></head>
3878<body>
3879<div>Content</div>
3880</body>
3881</html>"#;
3882 let doc = we_html::parse_html(html_str);
3883 let font = test_font();
3884 let sheets = extract_stylesheets(&doc);
3885 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3886 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3887
3888 let body_box = &tree.root.children[0];
3889 let div_box = &body_box.children[0];
3890
3891 // border-box: content width = 200 - 10*2 (padding) - 5*2 (border) = 170
3892 assert_eq!(div_box.rect.width, 170.0);
3893 assert_eq!(div_box.padding.left, 10.0);
3894 assert_eq!(div_box.padding.right, 10.0);
3895 assert_eq!(div_box.border.left, 5.0);
3896 assert_eq!(div_box.border.right, 5.0);
3897 }
3898
3899 #[test]
3900 fn border_box_padding_exceeds_width_clamps_to_zero() {
3901 // border-box with padding+border > specified width: content clamps to 0.
3902 let html_str = r#"<!DOCTYPE html>
3903<html>
3904<head><style>
3905body { margin: 0; }
3906div { box-sizing: border-box; width: 20px; padding: 15px; border: 5px solid black; }
3907</style></head>
3908<body>
3909<div>X</div>
3910</body>
3911</html>"#;
3912 let doc = we_html::parse_html(html_str);
3913 let font = test_font();
3914 let sheets = extract_stylesheets(&doc);
3915 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3916 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3917
3918 let body_box = &tree.root.children[0];
3919 let div_box = &body_box.children[0];
3920
3921 // border-box: content = 20 - 15*2 - 5*2 = 20 - 40 = -20 → clamped to 0
3922 assert_eq!(div_box.rect.width, 0.0);
3923 }
3924
3925 #[test]
3926 fn box_sizing_is_not_inherited() {
3927 // box-sizing is not inherited: child should use default content-box.
3928 let html_str = r#"<!DOCTYPE html>
3929<html>
3930<head><style>
3931body { margin: 0; }
3932.parent { box-sizing: border-box; width: 300px; padding: 10px; border: 5px solid black; }
3933.child { width: 100px; padding: 10px; border: 5px solid black; }
3934</style></head>
3935<body>
3936<div class="parent"><div class="child">Inner</div></div>
3937</body>
3938</html>"#;
3939 let doc = we_html::parse_html(html_str);
3940 let font = test_font();
3941 let sheets = extract_stylesheets(&doc);
3942 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3943 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3944
3945 let body_box = &tree.root.children[0];
3946 let parent_box = &body_box.children[0];
3947 let child_box = &parent_box.children[0];
3948
3949 // Parent: border-box → content = 300 - 20 - 10 = 270
3950 assert_eq!(parent_box.rect.width, 270.0);
3951 // Child: default content-box → content = 100 (not reduced by padding/border)
3952 assert_eq!(child_box.rect.width, 100.0);
3953 }
3954
3955 #[test]
3956 fn border_box_height() {
3957 // box-sizing: border-box also applies to height.
3958 let html_str = r#"<!DOCTYPE html>
3959<html>
3960<head><style>
3961body { margin: 0; }
3962div { box-sizing: border-box; height: 100px; padding: 10px; border: 5px solid black; }
3963</style></head>
3964<body>
3965<div>Content</div>
3966</body>
3967</html>"#;
3968 let doc = we_html::parse_html(html_str);
3969 let font = test_font();
3970 let sheets = extract_stylesheets(&doc);
3971 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3972 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3973
3974 let body_box = &tree.root.children[0];
3975 let div_box = &body_box.children[0];
3976
3977 // border-box: content height = 100 - 10*2 (padding) - 5*2 (border) = 70
3978 assert_eq!(div_box.rect.height, 70.0);
3979 }
3980
3981 #[test]
3982 fn content_box_explicit_height() {
3983 // content-box: height applies to content only.
3984 let html_str = r#"<!DOCTYPE html>
3985<html>
3986<head><style>
3987body { margin: 0; }
3988div { height: 100px; padding: 10px; border: 5px solid black; }
3989</style></head>
3990<body>
3991<div>Content</div>
3992</body>
3993</html>"#;
3994 let doc = we_html::parse_html(html_str);
3995 let font = test_font();
3996 let sheets = extract_stylesheets(&doc);
3997 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
3998 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
3999
4000 let body_box = &tree.root.children[0];
4001 let div_box = &body_box.children[0];
4002
4003 // content-box: rect.height = 100 (specified height IS content height)
4004 assert_eq!(div_box.rect.height, 100.0);
4005 }
4006
4007 // --- Visibility / display:none tests ---
4008
4009 #[test]
4010 fn display_none_excludes_from_layout_tree() {
4011 let html_str = r#"<!DOCTYPE html>
4012<html>
4013<head><style>
4014body { margin: 0; }
4015.hidden { display: none; }
4016p { margin: 0; }
4017</style></head>
4018<body>
4019<p>First</p>
4020<div class="hidden">Hidden content</div>
4021<p>Second</p>
4022</body>
4023</html>"#;
4024 let doc = we_html::parse_html(html_str);
4025 let font = test_font();
4026 let sheets = extract_stylesheets(&doc);
4027 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4028 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4029
4030 let body_box = &tree.root.children[0];
4031 // display:none element is excluded — body should have only 2 children.
4032 assert_eq!(body_box.children.len(), 2);
4033
4034 let first = &body_box.children[0];
4035 let second = &body_box.children[1];
4036 // Second paragraph should be directly below first (no gap for hidden).
4037 assert!(
4038 second.rect.y == first.rect.y + first.rect.height,
4039 "display:none should not occupy space"
4040 );
4041 }
4042
4043 #[test]
4044 fn visibility_hidden_preserves_layout_space() {
4045 let html_str = r#"<!DOCTYPE html>
4046<html>
4047<head><style>
4048body { margin: 0; }
4049.hidden { visibility: hidden; height: 50px; margin: 0; }
4050p { margin: 0; }
4051</style></head>
4052<body>
4053<p>First</p>
4054<div class="hidden">Hidden</div>
4055<p>Second</p>
4056</body>
4057</html>"#;
4058 let doc = we_html::parse_html(html_str);
4059 let font = test_font();
4060 let sheets = extract_stylesheets(&doc);
4061 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4062 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4063
4064 let body_box = &tree.root.children[0];
4065 // visibility:hidden still in layout tree — body has 3 children.
4066 assert_eq!(body_box.children.len(), 3);
4067
4068 let hidden_box = &body_box.children[1];
4069 assert_eq!(hidden_box.visibility, Visibility::Hidden);
4070 assert_eq!(hidden_box.rect.height, 50.0);
4071
4072 let second = &body_box.children[2];
4073 // Second paragraph should be below hidden div (it occupies 50px).
4074 assert!(
4075 second.rect.y >= hidden_box.rect.y + 50.0,
4076 "visibility:hidden should preserve layout space"
4077 );
4078 }
4079
4080 #[test]
4081 fn visibility_inherited_by_children() {
4082 let html_str = r#"<!DOCTYPE html>
4083<html>
4084<head><style>
4085body { margin: 0; }
4086.parent { visibility: hidden; }
4087</style></head>
4088<body>
4089<div class="parent"><p>Child</p></div>
4090</body>
4091</html>"#;
4092 let doc = we_html::parse_html(html_str);
4093 let font = test_font();
4094 let sheets = extract_stylesheets(&doc);
4095 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4096 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4097
4098 let body_box = &tree.root.children[0];
4099 let parent_box = &body_box.children[0];
4100 let child_box = &parent_box.children[0];
4101 assert_eq!(parent_box.visibility, Visibility::Hidden);
4102 assert_eq!(child_box.visibility, Visibility::Hidden);
4103 }
4104
4105 #[test]
4106 fn visibility_visible_overrides_hidden_parent() {
4107 let html_str = r#"<!DOCTYPE html>
4108<html>
4109<head><style>
4110body { margin: 0; }
4111.parent { visibility: hidden; }
4112.child { visibility: visible; }
4113</style></head>
4114<body>
4115<div class="parent"><p class="child">Visible child</p></div>
4116</body>
4117</html>"#;
4118 let doc = we_html::parse_html(html_str);
4119 let font = test_font();
4120 let sheets = extract_stylesheets(&doc);
4121 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4122 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4123
4124 let body_box = &tree.root.children[0];
4125 let parent_box = &body_box.children[0];
4126 let child_box = &parent_box.children[0];
4127 assert_eq!(parent_box.visibility, Visibility::Hidden);
4128 assert_eq!(child_box.visibility, Visibility::Visible);
4129 }
4130
4131 #[test]
4132 fn visibility_collapse_on_non_table_treated_as_hidden() {
4133 let html_str = r#"<!DOCTYPE html>
4134<html>
4135<head><style>
4136body { margin: 0; }
4137div { visibility: collapse; height: 50px; }
4138</style></head>
4139<body>
4140<div>Collapsed</div>
4141</body>
4142</html>"#;
4143 let doc = we_html::parse_html(html_str);
4144 let font = test_font();
4145 let sheets = extract_stylesheets(&doc);
4146 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4147 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4148
4149 let body_box = &tree.root.children[0];
4150 let div_box = &body_box.children[0];
4151 assert_eq!(div_box.visibility, Visibility::Collapse);
4152 // Still occupies space (non-table collapse = hidden behavior).
4153 assert_eq!(div_box.rect.height, 50.0);
4154 }
4155
4156 // --- Viewport units and percentage resolution tests ---
4157
4158 #[test]
4159 fn width_50_percent_resolves_to_half_containing_block() {
4160 let html_str = r#"<!DOCTYPE html>
4161<html>
4162<head><style>
4163body { margin: 0; }
4164div { width: 50%; }
4165</style></head>
4166<body><div>Half width</div></body>
4167</html>"#;
4168 let doc = we_html::parse_html(html_str);
4169 let font = test_font();
4170 let sheets = extract_stylesheets(&doc);
4171 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4172 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4173
4174 let body_box = &tree.root.children[0];
4175 let div_box = &body_box.children[0];
4176 assert!(
4177 (div_box.rect.width - 400.0).abs() < 0.01,
4178 "width: 50% should be 400px on 800px viewport, got {}",
4179 div_box.rect.width
4180 );
4181 }
4182
4183 #[test]
4184 fn margin_10_percent_resolves_against_containing_block_width() {
4185 let html_str = r#"<!DOCTYPE html>
4186<html>
4187<head><style>
4188body { margin: 0; }
4189div { margin: 10%; width: 100px; height: 50px; }
4190</style></head>
4191<body><div>Box</div></body>
4192</html>"#;
4193 let doc = we_html::parse_html(html_str);
4194 let font = test_font();
4195 let sheets = extract_stylesheets(&doc);
4196 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4197 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4198
4199 let body_box = &tree.root.children[0];
4200 let div_box = &body_box.children[0];
4201 // All margins (including top/bottom) resolve against containing block WIDTH.
4202 assert!(
4203 (div_box.margin.left - 80.0).abs() < 0.01,
4204 "margin-left: 10% should be 80px on 800px viewport, got {}",
4205 div_box.margin.left
4206 );
4207 assert!(
4208 (div_box.margin.top - 80.0).abs() < 0.01,
4209 "margin-top: 10% should be 80px (against width, not height), got {}",
4210 div_box.margin.top
4211 );
4212 }
4213
4214 #[test]
4215 fn width_50vw_resolves_to_half_viewport() {
4216 let html_str = r#"<!DOCTYPE html>
4217<html>
4218<head><style>
4219body { margin: 0; }
4220div { width: 50vw; }
4221</style></head>
4222<body><div>Half viewport</div></body>
4223</html>"#;
4224 let doc = we_html::parse_html(html_str);
4225 let font = test_font();
4226 let sheets = extract_stylesheets(&doc);
4227 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4228 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4229
4230 let body_box = &tree.root.children[0];
4231 let div_box = &body_box.children[0];
4232 assert!(
4233 (div_box.rect.width - 400.0).abs() < 0.01,
4234 "width: 50vw should be 400px on 800px viewport, got {}",
4235 div_box.rect.width
4236 );
4237 }
4238
4239 #[test]
4240 fn height_100vh_resolves_to_full_viewport() {
4241 let html_str = r#"<!DOCTYPE html>
4242<html>
4243<head><style>
4244body { margin: 0; }
4245div { height: 100vh; }
4246</style></head>
4247<body><div>Full height</div></body>
4248</html>"#;
4249 let doc = we_html::parse_html(html_str);
4250 let font = test_font();
4251 let sheets = extract_stylesheets(&doc);
4252 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4253 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4254
4255 let body_box = &tree.root.children[0];
4256 let div_box = &body_box.children[0];
4257 assert!(
4258 (div_box.rect.height - 600.0).abs() < 0.01,
4259 "height: 100vh should be 600px on 600px viewport, got {}",
4260 div_box.rect.height
4261 );
4262 }
4263
4264 #[test]
4265 fn font_size_5vmin_resolves_to_smaller_dimension() {
4266 let html_str = r#"<!DOCTYPE html>
4267<html>
4268<head><style>
4269body { margin: 0; }
4270p { font-size: 5vmin; }
4271</style></head>
4272<body><p>Text</p></body>
4273</html>"#;
4274 let doc = we_html::parse_html(html_str);
4275 let font = test_font();
4276 let sheets = extract_stylesheets(&doc);
4277 // viewport 800x600 → vmin = 600
4278 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4279 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4280
4281 let body_box = &tree.root.children[0];
4282 let p_box = &body_box.children[0];
4283 // 5vmin = 5% of min(800, 600) = 5% of 600 = 30px
4284 assert!(
4285 (p_box.font_size - 30.0).abs() < 0.01,
4286 "font-size: 5vmin should be 30px, got {}",
4287 p_box.font_size
4288 );
4289 }
4290
4291 #[test]
4292 fn nested_percentage_widths_compound() {
4293 let html_str = r#"<!DOCTYPE html>
4294<html>
4295<head><style>
4296body { margin: 0; }
4297.outer { width: 50%; }
4298.inner { width: 50%; }
4299</style></head>
4300<body>
4301<div class="outer"><div class="inner">Nested</div></div>
4302</body>
4303</html>"#;
4304 let doc = we_html::parse_html(html_str);
4305 let font = test_font();
4306 let sheets = extract_stylesheets(&doc);
4307 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4308 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4309
4310 let body_box = &tree.root.children[0];
4311 let outer_box = &body_box.children[0];
4312 let inner_box = &outer_box.children[0];
4313 // outer = 50% of 800 = 400
4314 assert!(
4315 (outer_box.rect.width - 400.0).abs() < 0.01,
4316 "outer width should be 400px, got {}",
4317 outer_box.rect.width
4318 );
4319 // inner = 50% of 400 = 200
4320 assert!(
4321 (inner_box.rect.width - 200.0).abs() < 0.01,
4322 "inner width should be 200px (50% of 400), got {}",
4323 inner_box.rect.width
4324 );
4325 }
4326
4327 #[test]
4328 fn padding_percent_resolves_against_width() {
4329 let html_str = r#"<!DOCTYPE html>
4330<html>
4331<head><style>
4332body { margin: 0; }
4333div { padding: 5%; width: 400px; }
4334</style></head>
4335<body><div>Content</div></body>
4336</html>"#;
4337 let doc = we_html::parse_html(html_str);
4338 let font = test_font();
4339 let sheets = extract_stylesheets(&doc);
4340 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4341 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4342
4343 let body_box = &tree.root.children[0];
4344 let div_box = &body_box.children[0];
4345 // padding: 5% resolves against containing block width (800px)
4346 assert!(
4347 (div_box.padding.top - 40.0).abs() < 0.01,
4348 "padding-top: 5% should be 40px (5% of 800), got {}",
4349 div_box.padding.top
4350 );
4351 assert!(
4352 (div_box.padding.left - 40.0).abs() < 0.01,
4353 "padding-left: 5% should be 40px (5% of 800), got {}",
4354 div_box.padding.left
4355 );
4356 }
4357
4358 #[test]
4359 fn vmax_uses_larger_dimension() {
4360 let html_str = r#"<!DOCTYPE html>
4361<html>
4362<head><style>
4363body { margin: 0; }
4364div { width: 10vmax; }
4365</style></head>
4366<body><div>Content</div></body>
4367</html>"#;
4368 let doc = we_html::parse_html(html_str);
4369 let font = test_font();
4370 let sheets = extract_stylesheets(&doc);
4371 // viewport 800x600 → vmax = 800
4372 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4373 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4374
4375 let body_box = &tree.root.children[0];
4376 let div_box = &body_box.children[0];
4377 // 10vmax = 10% of max(800, 600) = 10% of 800 = 80px
4378 assert!(
4379 (div_box.rect.width - 80.0).abs() < 0.01,
4380 "width: 10vmax should be 80px, got {}",
4381 div_box.rect.width
4382 );
4383 }
4384
4385 #[test]
4386 fn height_50_percent_resolves_against_viewport() {
4387 let html_str = r#"<!DOCTYPE html>
4388<html>
4389<head><style>
4390body { margin: 0; }
4391div { height: 50%; }
4392</style></head>
4393<body><div>Half height</div></body>
4394</html>"#;
4395 let doc = we_html::parse_html(html_str);
4396 let font = test_font();
4397 let sheets = extract_stylesheets(&doc);
4398 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap();
4399 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new());
4400
4401 let body_box = &tree.root.children[0];
4402 let div_box = &body_box.children[0];
4403 // height: 50% resolves against viewport height (600)
4404 assert!(
4405 (div_box.rect.height - 300.0).abs() < 0.01,
4406 "height: 50% should be 300px on 600px viewport, got {}",
4407 div_box.rect.height
4408 );
4409 }
4410
4411 // -----------------------------------------------------------------------
4412 // Flexbox tests
4413 // -----------------------------------------------------------------------
4414
4415 #[test]
4416 fn flex_row_default_items_laid_out_horizontally() {
4417 let html_str = r#"<!DOCTYPE html>
4418<html><head><style>
4419body { margin: 0; }
4420.container { display: flex; width: 600px; }
4421.item { width: 100px; height: 50px; }
4422</style></head>
4423<body>
4424<div class="container">
4425 <div class="item">A</div>
4426 <div class="item">B</div>
4427 <div class="item">C</div>
4428</div>
4429</body></html>"#;
4430 let doc = we_html::parse_html(html_str);
4431 let tree = layout_doc(&doc);
4432
4433 let body = &tree.root.children[0];
4434 let container = &body.children[0];
4435 assert_eq!(container.children.len(), 3);
4436
4437 // Items should be at x=0, 100, 200
4438 assert!(
4439 (container.children[0].rect.x - 0.0).abs() < 1.0,
4440 "first item x should be 0, got {}",
4441 container.children[0].rect.x
4442 );
4443 assert!(
4444 (container.children[1].rect.x - 100.0).abs() < 1.0,
4445 "second item x should be 100, got {}",
4446 container.children[1].rect.x
4447 );
4448 assert!(
4449 (container.children[2].rect.x - 200.0).abs() < 1.0,
4450 "third item x should be 200, got {}",
4451 container.children[2].rect.x
4452 );
4453
4454 // All items should have the same y.
4455 let y0 = container.children[0].rect.y;
4456 assert!((container.children[1].rect.y - y0).abs() < 1.0);
4457 assert!((container.children[2].rect.y - y0).abs() < 1.0);
4458 }
4459
4460 #[test]
4461 fn flex_direction_column() {
4462 let html_str = r#"<!DOCTYPE html>
4463<html><head><style>
4464body { margin: 0; }
4465.container { display: flex; flex-direction: column; width: 600px; }
4466.item { height: 50px; }
4467</style></head>
4468<body>
4469<div class="container">
4470 <div class="item">A</div>
4471 <div class="item">B</div>
4472 <div class="item">C</div>
4473</div>
4474</body></html>"#;
4475 let doc = we_html::parse_html(html_str);
4476 let tree = layout_doc(&doc);
4477
4478 let body = &tree.root.children[0];
4479 let container = &body.children[0];
4480 assert_eq!(container.children.len(), 3);
4481
4482 // Items should be stacked vertically at y offsets: 0, 50, 100 (relative to container).
4483 let cy = container.rect.y;
4484 assert!((container.children[0].rect.y - cy).abs() < 1.0);
4485 assert!(
4486 (container.children[1].rect.y - cy - 50.0).abs() < 1.0,
4487 "second item y should be {}, got {}",
4488 cy + 50.0,
4489 container.children[1].rect.y
4490 );
4491 assert!(
4492 (container.children[2].rect.y - cy - 100.0).abs() < 1.0,
4493 "third item y should be {}, got {}",
4494 cy + 100.0,
4495 container.children[2].rect.y
4496 );
4497 }
4498
4499 #[test]
4500 fn flex_grow_distributes_space() {
4501 let html_str = r#"<!DOCTYPE html>
4502<html><head><style>
4503body { margin: 0; }
4504.container { display: flex; width: 600px; }
4505.a { flex-grow: 1; flex-basis: 0; height: 50px; }
4506.b { flex-grow: 2; flex-basis: 0; height: 50px; }
4507.c { flex-grow: 1; flex-basis: 0; height: 50px; }
4508</style></head>
4509<body>
4510<div class="container">
4511 <div class="a">A</div>
4512 <div class="b">B</div>
4513 <div class="c">C</div>
4514</div>
4515</body></html>"#;
4516 let doc = we_html::parse_html(html_str);
4517 let tree = layout_doc(&doc);
4518
4519 let body = &tree.root.children[0];
4520 let container = &body.children[0];
4521
4522 // flex-grow 1:2:1 should split 600px as 150:300:150
4523 let a = &container.children[0];
4524 let b = &container.children[1];
4525 let c = &container.children[2];
4526
4527 assert!(
4528 (a.rect.width - 150.0).abs() < 1.0,
4529 "item A width should be 150, got {}",
4530 a.rect.width
4531 );
4532 assert!(
4533 (b.rect.width - 300.0).abs() < 1.0,
4534 "item B width should be 300, got {}",
4535 b.rect.width
4536 );
4537 assert!(
4538 (c.rect.width - 150.0).abs() < 1.0,
4539 "item C width should be 150, got {}",
4540 c.rect.width
4541 );
4542 }
4543
4544 #[test]
4545 fn flex_justify_content_center() {
4546 let html_str = r#"<!DOCTYPE html>
4547<html><head><style>
4548body { margin: 0; }
4549.container { display: flex; width: 600px; justify-content: center; }
4550.item { width: 100px; height: 50px; }
4551</style></head>
4552<body>
4553<div class="container">
4554 <div class="item">A</div>
4555 <div class="item">B</div>
4556</div>
4557</body></html>"#;
4558 let doc = we_html::parse_html(html_str);
4559 let tree = layout_doc(&doc);
4560
4561 let body = &tree.root.children[0];
4562 let container = &body.children[0];
4563
4564 // 600px container, 200px of items, 400px free space, offset = 200.
4565 let a = &container.children[0];
4566 let b = &container.children[1];
4567
4568 assert!(
4569 (a.rect.x - 200.0).abs() < 1.0,
4570 "first item x should be 200, got {}",
4571 a.rect.x
4572 );
4573 assert!(
4574 (b.rect.x - 300.0).abs() < 1.0,
4575 "second item x should be 300, got {}",
4576 b.rect.x
4577 );
4578 }
4579
4580 #[test]
4581 fn flex_justify_content_space_between() {
4582 let html_str = r#"<!DOCTYPE html>
4583<html><head><style>
4584body { margin: 0; }
4585.container { display: flex; width: 600px; justify-content: space-between; }
4586.item { width: 100px; height: 50px; }
4587</style></head>
4588<body>
4589<div class="container">
4590 <div class="item">A</div>
4591 <div class="item">B</div>
4592 <div class="item">C</div>
4593</div>
4594</body></html>"#;
4595 let doc = we_html::parse_html(html_str);
4596 let tree = layout_doc(&doc);
4597
4598 let body = &tree.root.children[0];
4599 let container = &body.children[0];
4600
4601 // 3 items of 100px each = 300px, 300px free, between = 150
4602 // Positions: 0, 250, 500
4603 let a = &container.children[0];
4604 let b = &container.children[1];
4605 let c = &container.children[2];
4606
4607 assert!(
4608 (a.rect.x - 0.0).abs() < 1.0,
4609 "first item x should be 0, got {}",
4610 a.rect.x
4611 );
4612 assert!(
4613 (b.rect.x - 250.0).abs() < 1.0,
4614 "second item x should be 250, got {}",
4615 b.rect.x
4616 );
4617 assert!(
4618 (c.rect.x - 500.0).abs() < 1.0,
4619 "third item x should be 500, got {}",
4620 c.rect.x
4621 );
4622 }
4623
4624 #[test]
4625 fn flex_align_items_center() {
4626 let html_str = r#"<!DOCTYPE html>
4627<html><head><style>
4628body { margin: 0; }
4629.container { display: flex; width: 600px; height: 200px; align-items: center; }
4630.item { width: 100px; height: 50px; }
4631</style></head>
4632<body>
4633<div class="container">
4634 <div class="item">A</div>
4635</div>
4636</body></html>"#;
4637 let doc = we_html::parse_html(html_str);
4638 let tree = layout_doc(&doc);
4639
4640 let body = &tree.root.children[0];
4641 let container = &body.children[0];
4642 let a = &container.children[0];
4643
4644 // Container height=200, item height=50, centered at y = container.y + 75
4645 let expected_y = container.rect.y + 75.0;
4646 assert!(
4647 (a.rect.y - expected_y).abs() < 1.0,
4648 "item y should be {}, got {}",
4649 expected_y,
4650 a.rect.y
4651 );
4652 }
4653
4654 #[test]
4655 fn flex_wrap_wraps_to_new_line() {
4656 let html_str = r#"<!DOCTYPE html>
4657<html><head><style>
4658body { margin: 0; }
4659.container { display: flex; flex-wrap: wrap; width: 250px; }
4660.item { width: 100px; height: 50px; }
4661</style></head>
4662<body>
4663<div class="container">
4664 <div class="item">A</div>
4665 <div class="item">B</div>
4666 <div class="item">C</div>
4667</div>
4668</body></html>"#;
4669 let doc = we_html::parse_html(html_str);
4670 let tree = layout_doc(&doc);
4671
4672 let body = &tree.root.children[0];
4673 let container = &body.children[0];
4674
4675 // 250px container, 100px items: A and B fit on line 1, C wraps to line 2
4676 let a = &container.children[0];
4677 let b = &container.children[1];
4678 let c = &container.children[2];
4679
4680 // A and B should be on the same row
4681 assert!((a.rect.y - b.rect.y).abs() < 1.0);
4682 // C should be on a different row (50px below)
4683 assert!(
4684 (c.rect.y - a.rect.y - 50.0).abs() < 1.0,
4685 "C should be 50px below A, got y diff {}",
4686 c.rect.y - a.rect.y
4687 );
4688 }
4689
4690 #[test]
4691 fn flex_gap_adds_spacing() {
4692 let html_str = r#"<!DOCTYPE html>
4693<html><head><style>
4694body { margin: 0; }
4695.container { display: flex; width: 600px; gap: 20px; }
4696.item { width: 100px; height: 50px; }
4697</style></head>
4698<body>
4699<div class="container">
4700 <div class="item">A</div>
4701 <div class="item">B</div>
4702 <div class="item">C</div>
4703</div>
4704</body></html>"#;
4705 let doc = we_html::parse_html(html_str);
4706 let tree = layout_doc(&doc);
4707
4708 let body = &tree.root.children[0];
4709 let container = &body.children[0];
4710
4711 let a = &container.children[0];
4712 let b = &container.children[1];
4713 let c = &container.children[2];
4714
4715 // With gap: 20px, positions should be: 0, 120, 240
4716 assert!((a.rect.x - 0.0).abs() < 1.0);
4717 assert!(
4718 (b.rect.x - 120.0).abs() < 1.0,
4719 "B x should be 120, got {}",
4720 b.rect.x
4721 );
4722 assert!(
4723 (c.rect.x - 240.0).abs() < 1.0,
4724 "C x should be 240, got {}",
4725 c.rect.x
4726 );
4727 }
4728
4729 #[test]
4730 fn flex_order_changes_visual_order() {
4731 let html_str = r#"<!DOCTYPE html>
4732<html><head><style>
4733body { margin: 0; }
4734.container { display: flex; width: 600px; }
4735.a { width: 100px; height: 50px; order: 2; }
4736.b { width: 100px; height: 50px; order: 1; }
4737.c { width: 100px; height: 50px; order: 3; }
4738</style></head>
4739<body>
4740<div class="container">
4741 <div class="a">A</div>
4742 <div class="b">B</div>
4743 <div class="c">C</div>
4744</div>
4745</body></html>"#;
4746 let doc = we_html::parse_html(html_str);
4747 let tree = layout_doc(&doc);
4748
4749 let body = &tree.root.children[0];
4750 let container = &body.children[0];
4751
4752 // DOM order: A(order:2), B(order:1), C(order:3)
4753 // Visual order: B(1), A(2), C(3)
4754 // B is at index 1 in DOM, A at 0, C at 2.
4755 // B (index 1) should be first visually (x ≈ 0)
4756 // A (index 0) should be second visually (x ≈ 100)
4757 // C (index 2) should be third visually (x ≈ 200)
4758 let a = &container.children[0]; // DOM index 0, order 2
4759 let b = &container.children[1]; // DOM index 1, order 1
4760 let c = &container.children[2]; // DOM index 2, order 3
4761
4762 assert!(
4763 b.rect.x < a.rect.x,
4764 "B (order:1) should be before A (order:2), B.x={} A.x={}",
4765 b.rect.x,
4766 a.rect.x
4767 );
4768 assert!(
4769 a.rect.x < c.rect.x,
4770 "A (order:2) should be before C (order:3), A.x={} C.x={}",
4771 a.rect.x,
4772 c.rect.x
4773 );
4774 }
4775
4776 #[test]
4777 fn flex_shrink_items() {
4778 let html_str = r#"<!DOCTYPE html>
4779<html><head><style>
4780body { margin: 0; }
4781.container { display: flex; width: 200px; }
4782.item { width: 100px; height: 50px; flex-shrink: 1; }
4783</style></head>
4784<body>
4785<div class="container">
4786 <div class="item">A</div>
4787 <div class="item">B</div>
4788 <div class="item">C</div>
4789</div>
4790</body></html>"#;
4791 let doc = we_html::parse_html(html_str);
4792 let tree = layout_doc(&doc);
4793
4794 let body = &tree.root.children[0];
4795 let container = &body.children[0];
4796
4797 // 3 items * 100px = 300px in 200px container, shrink evenly
4798 // Each should be ~66.67px
4799 for (i, child) in container.children.iter().enumerate() {
4800 assert!(
4801 (child.rect.width - 200.0 / 3.0).abs() < 1.0,
4802 "item {} width should be ~66.67, got {}",
4803 i,
4804 child.rect.width
4805 );
4806 }
4807 }
4808
4809 // -----------------------------------------------------------------------
4810 // Absolute positioning tests
4811 // -----------------------------------------------------------------------
4812
4813 /// Helper to create: <html><body>...content...</body></html> and return
4814 /// (doc, html_id, body_id).
4815 fn make_html_body(doc: &mut Document) -> (NodeId, NodeId, NodeId) {
4816 let root = doc.root();
4817 let html = doc.create_element("html");
4818 let body = doc.create_element("body");
4819 doc.append_child(root, html);
4820 doc.append_child(html, body);
4821 (root, html, body)
4822 }
4823
4824 #[test]
4825 fn absolute_positioned_with_top_left() {
4826 let mut doc = Document::new();
4827 let (_, _, body) = make_html_body(&mut doc);
4828 // <div style="position: relative; width: 400px; height: 300px;">
4829 // <div style="position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;"></div>
4830 // </div>
4831 let container = doc.create_element("div");
4832 let abs_child = doc.create_element("div");
4833 doc.append_child(body, container);
4834 doc.append_child(container, abs_child);
4835 doc.set_attribute(
4836 container,
4837 "style",
4838 "position: relative; width: 400px; height: 300px;",
4839 );
4840 doc.set_attribute(
4841 abs_child,
4842 "style",
4843 "position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;",
4844 );
4845
4846 let tree = layout_doc(&doc);
4847 let body_box = &tree.root.children[0];
4848 let container_box = &body_box.children[0];
4849 let abs_box = &container_box.children[0];
4850
4851 assert_eq!(abs_box.position, Position::Absolute);
4852 assert_eq!(abs_box.rect.width, 100.0);
4853 assert_eq!(abs_box.rect.height, 50.0);
4854
4855 // The containing block is the container's padding box.
4856 // Container content starts at container_box.rect.x, container_box.rect.y.
4857 // Padding box x = container_box.rect.x - container_box.padding.left.
4858 let cb_x = container_box.rect.x - container_box.padding.left;
4859 let cb_y = container_box.rect.y - container_box.padding.top;
4860
4861 // abs_box content area x = cb_x + 20 (left) + 0 (margin) + 0 (border) + 0 (padding)
4862 assert!(
4863 (abs_box.rect.x - (cb_x + 20.0)).abs() < 0.01,
4864 "abs x should be cb_x + 20, got {} (cb_x={})",
4865 abs_box.rect.x,
4866 cb_x,
4867 );
4868 assert!(
4869 (abs_box.rect.y - (cb_y + 10.0)).abs() < 0.01,
4870 "abs y should be cb_y + 10, got {} (cb_y={})",
4871 abs_box.rect.y,
4872 cb_y,
4873 );
4874 }
4875
4876 #[test]
4877 fn absolute_does_not_affect_sibling_layout() {
4878 let mut doc = Document::new();
4879 let (_, _, body) = make_html_body(&mut doc);
4880 // <div style="position: relative;">
4881 // <div style="position: absolute; top: 0; left: 0; width: 100px; height: 100px;"></div>
4882 // <p>Normal text</p>
4883 // </div>
4884 let container = doc.create_element("div");
4885 let abs_child = doc.create_element("div");
4886 let p = doc.create_element("p");
4887 let text = doc.create_text("Normal text");
4888 doc.append_child(body, container);
4889 doc.append_child(container, abs_child);
4890 doc.append_child(container, p);
4891 doc.append_child(p, text);
4892 doc.set_attribute(container, "style", "position: relative;");
4893 doc.set_attribute(
4894 abs_child,
4895 "style",
4896 "position: absolute; top: 0; left: 0; width: 100px; height: 100px;",
4897 );
4898
4899 let tree = layout_doc(&doc);
4900 let body_box = &tree.root.children[0];
4901 let container_box = &body_box.children[0];
4902
4903 // The <p> should start at the top of the container (abs child doesn't
4904 // push it down). Find the first in-flow child.
4905 let p_box = container_box
4906 .children
4907 .iter()
4908 .find(|c| c.position != Position::Absolute && c.position != Position::Fixed)
4909 .expect("should have an in-flow child");
4910
4911 // p's y should be at (or very near) the container's content y.
4912 let expected_y = container_box.rect.y;
4913 assert!(
4914 (p_box.rect.y - expected_y).abs() < 1.0,
4915 "p should start near container top: p.y={}, expected={}",
4916 p_box.rect.y,
4917 expected_y,
4918 );
4919 }
4920
4921 #[test]
4922 fn fixed_positioned_relative_to_viewport() {
4923 let mut doc = Document::new();
4924 let (_, _, body) = make_html_body(&mut doc);
4925 // <div style="position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;"></div>
4926 let fixed = doc.create_element("div");
4927 doc.append_child(body, fixed);
4928 doc.set_attribute(
4929 fixed,
4930 "style",
4931 "position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;",
4932 );
4933
4934 let tree = layout_doc(&doc);
4935 let body_box = &tree.root.children[0];
4936 let fixed_box = &body_box.children[0];
4937
4938 assert_eq!(fixed_box.position, Position::Fixed);
4939 assert_eq!(fixed_box.rect.width, 50.0);
4940 assert_eq!(fixed_box.rect.height, 30.0);
4941
4942 // Fixed should be relative to viewport (0,0).
4943 assert!(
4944 (fixed_box.rect.x - 10.0).abs() < 0.01,
4945 "fixed x should be 10, got {}",
4946 fixed_box.rect.x,
4947 );
4948 assert!(
4949 (fixed_box.rect.y - 5.0).abs() < 0.01,
4950 "fixed y should be 5, got {}",
4951 fixed_box.rect.y,
4952 );
4953 }
4954
4955 #[test]
4956 fn z_index_ordering() {
4957 let mut doc = Document::new();
4958 let (_, _, body) = make_html_body(&mut doc);
4959 // Create a positioned container with two abs children at different z-index.
4960 let container = doc.create_element("div");
4961 let low = doc.create_element("div");
4962 let high = doc.create_element("div");
4963 doc.append_child(body, container);
4964 doc.append_child(container, low);
4965 doc.append_child(container, high);
4966 doc.set_attribute(
4967 container,
4968 "style",
4969 "position: relative; width: 200px; height: 200px;",
4970 );
4971 doc.set_attribute(
4972 low,
4973 "style",
4974 "position: absolute; z-index: 1; top: 0; left: 0; width: 50px; height: 50px;",
4975 );
4976 doc.set_attribute(
4977 high,
4978 "style",
4979 "position: absolute; z-index: 5; top: 0; left: 0; width: 50px; height: 50px;",
4980 );
4981
4982 let tree = layout_doc(&doc);
4983 let body_box = &tree.root.children[0];
4984 let container_box = &body_box.children[0];
4985
4986 // Both should have correct z-index stored.
4987 let low_box = &container_box.children[0];
4988 let high_box = &container_box.children[1];
4989 assert_eq!(low_box.z_index, Some(1));
4990 assert_eq!(high_box.z_index, Some(5));
4991 }
4992
4993 #[test]
4994 fn absolute_stretching_left_right() {
4995 let mut doc = Document::new();
4996 let (_, _, body) = make_html_body(&mut doc);
4997 // <div style="position: relative; width: 400px; height: 300px;">
4998 // <div style="position: absolute; left: 10px; right: 20px; height: 50px;"></div>
4999 // </div>
5000 let container = doc.create_element("div");
5001 let abs_child = doc.create_element("div");
5002 doc.append_child(body, container);
5003 doc.append_child(container, abs_child);
5004 doc.set_attribute(
5005 container,
5006 "style",
5007 "position: relative; width: 400px; height: 300px;",
5008 );
5009 doc.set_attribute(
5010 abs_child,
5011 "style",
5012 "position: absolute; left: 10px; right: 20px; height: 50px;",
5013 );
5014
5015 let tree = layout_doc(&doc);
5016 let body_box = &tree.root.children[0];
5017 let container_box = &body_box.children[0];
5018 let abs_box = &container_box.children[0];
5019
5020 // Width should be: container_width - left - right = 400 - 10 - 20 = 370
5021 assert!(
5022 (abs_box.rect.width - 370.0).abs() < 0.01,
5023 "stretched width should be 370, got {}",
5024 abs_box.rect.width,
5025 );
5026 }
5027
5028 #[test]
5029 fn absolute_bottom_right_positioning() {
5030 let mut doc = Document::new();
5031 let (_, _, body) = make_html_body(&mut doc);
5032 // <div style="position: relative; width: 400px; height: 300px;">
5033 // <div style="position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;"></div>
5034 // </div>
5035 let container = doc.create_element("div");
5036 let abs_child = doc.create_element("div");
5037 doc.append_child(body, container);
5038 doc.append_child(container, abs_child);
5039 doc.set_attribute(
5040 container,
5041 "style",
5042 "position: relative; width: 400px; height: 300px;",
5043 );
5044 doc.set_attribute(
5045 abs_child,
5046 "style",
5047 "position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;",
5048 );
5049
5050 let tree = layout_doc(&doc);
5051 let body_box = &tree.root.children[0];
5052 let container_box = &body_box.children[0];
5053 let abs_box = &container_box.children[0];
5054
5055 let cb_x = container_box.rect.x - container_box.padding.left;
5056 let cb_y = container_box.rect.y - container_box.padding.top;
5057 let cb_w =
5058 container_box.rect.width + container_box.padding.left + container_box.padding.right;
5059 let cb_h =
5060 container_box.rect.height + container_box.padding.top + container_box.padding.bottom;
5061
5062 // x = cb_x + cb_w - right - width = cb_x + cb_w - 20 - 50
5063 let expected_x = cb_x + cb_w - 20.0 - 50.0;
5064 // y = cb_y + cb_h - bottom - height = cb_y + cb_h - 10 - 30
5065 let expected_y = cb_y + cb_h - 10.0 - 30.0;
5066
5067 assert!(
5068 (abs_box.rect.x - expected_x).abs() < 0.01,
5069 "abs x with right should be {}, got {}",
5070 expected_x,
5071 abs_box.rect.x,
5072 );
5073 assert!(
5074 (abs_box.rect.y - expected_y).abs() < 0.01,
5075 "abs y with bottom should be {}, got {}",
5076 expected_y,
5077 abs_box.rect.y,
5078 );
5079 }
5080
5081 // -----------------------------------------------------------------------
5082 // Sticky positioning tests
5083 // -----------------------------------------------------------------------
5084
5085 #[test]
5086 fn sticky_element_laid_out_in_normal_flow() {
5087 // A sticky element should participate in normal flow just like a
5088 // static or relative element — its siblings should be positioned
5089 // as if sticky doesn't exist.
5090 let mut doc = Document::new();
5091 let (_, _, body) = make_html_body(&mut doc);
5092 let container = doc.create_element("div");
5093 let before = doc.create_element("div");
5094 let sticky = doc.create_element("div");
5095 let after = doc.create_element("div");
5096 doc.append_child(body, container);
5097 doc.append_child(container, before);
5098 doc.append_child(container, sticky);
5099 doc.append_child(container, after);
5100
5101 doc.set_attribute(container, "style", "width: 400px;");
5102 doc.set_attribute(before, "style", "height: 50px;");
5103 doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 30px;");
5104 doc.set_attribute(after, "style", "height: 60px;");
5105
5106 let tree = layout_doc(&doc);
5107 let body_box = &tree.root.children[0];
5108 let container_box = &body_box.children[0];
5109
5110 // All three children should be present (sticky is in-flow).
5111 let in_flow: Vec<&LayoutBox> = container_box.children.iter().collect();
5112 assert!(
5113 in_flow.len() >= 3,
5114 "expected 3 children, got {}",
5115 in_flow.len()
5116 );
5117
5118 let before_box = &in_flow[0];
5119 let sticky_box = &in_flow[1];
5120 let after_box = &in_flow[2];
5121
5122 assert_eq!(sticky_box.position, Position::Sticky);
5123
5124 // Sticky element should be right after 'before' (at y = before.y + 50).
5125 let expected_sticky_y = before_box.rect.y + 50.0;
5126 assert!(
5127 (sticky_box.rect.y - expected_sticky_y).abs() < 1.0,
5128 "sticky y should be ~{}, got {}",
5129 expected_sticky_y,
5130 sticky_box.rect.y,
5131 );
5132
5133 // 'after' should follow the sticky element (at y = sticky.y + 30).
5134 let expected_after_y = sticky_box.rect.y + 30.0;
5135 assert!(
5136 (after_box.rect.y - expected_after_y).abs() < 1.0,
5137 "after y should be ~{}, got {}",
5138 expected_after_y,
5139 after_box.rect.y,
5140 );
5141 }
5142
5143 #[test]
5144 fn sticky_constraint_rect_is_set() {
5145 // The layout engine should set `sticky_constraint` to the parent's
5146 // content rect for sticky children.
5147 let mut doc = Document::new();
5148 let (_, _, body) = make_html_body(&mut doc);
5149 let container = doc.create_element("div");
5150 let sticky = doc.create_element("div");
5151 doc.append_child(body, container);
5152 doc.append_child(container, sticky);
5153
5154 doc.set_attribute(container, "style", "width: 400px; height: 300px;");
5155 doc.set_attribute(sticky, "style", "position: sticky; top: 0; height: 50px;");
5156
5157 let tree = layout_doc(&doc);
5158 let body_box = &tree.root.children[0];
5159 let container_box = &body_box.children[0];
5160 let sticky_box = &container_box.children[0];
5161
5162 assert_eq!(sticky_box.position, Position::Sticky);
5163 let constraint = sticky_box
5164 .sticky_constraint
5165 .expect("sticky element should have a constraint rect");
5166
5167 // Constraint should match the container's content rect.
5168 assert!(
5169 (constraint.width - container_box.rect.width).abs() < 0.01,
5170 "constraint width should match container: {} vs {}",
5171 constraint.width,
5172 container_box.rect.width,
5173 );
5174 assert!(
5175 (constraint.height - container_box.rect.height).abs() < 0.01,
5176 "constraint height should match container: {} vs {}",
5177 constraint.height,
5178 container_box.rect.height,
5179 );
5180 }
5181
5182 // -----------------------------------------------------------------------
5183 // Float layout tests
5184 // -----------------------------------------------------------------------
5185
5186 #[test]
5187 fn float_left_positioned_at_left_edge() {
5188 // A float:left element should be placed at the left edge of the container.
5189 let mut doc = Document::new();
5190 let root = doc.root();
5191 let html = doc.create_element("html");
5192 let body = doc.create_element("body");
5193 let container = doc.create_element("div");
5194 let float_elem = doc.create_element("div");
5195 doc.append_child(root, html);
5196 doc.append_child(html, body);
5197 doc.append_child(body, container);
5198 doc.append_child(container, float_elem);
5199 doc.set_attribute(container, "style", "width: 400px;");
5200 doc.set_attribute(
5201 float_elem,
5202 "style",
5203 "float: left; width: 100px; height: 50px;",
5204 );
5205
5206 let tree = layout_doc(&doc);
5207 let body_box = &tree.root.children[0];
5208 let container_box = &body_box.children[0];
5209 let float_box = &container_box.children[0];
5210
5211 assert_eq!(float_box.float, Float::Left);
5212 assert!(
5213 (float_box.rect.width - 100.0).abs() < 0.01,
5214 "float width: {}",
5215 float_box.rect.width
5216 );
5217 assert!(
5218 (float_box.rect.height - 50.0).abs() < 0.01,
5219 "float height: {}",
5220 float_box.rect.height
5221 );
5222 // Float should be at the left edge of the container (same x as container content).
5223 assert!(
5224 (float_box.rect.x - container_box.rect.x).abs() < 0.01,
5225 "float x should be at container left edge: {} vs {}",
5226 float_box.rect.x,
5227 container_box.rect.x,
5228 );
5229 }
5230
5231 #[test]
5232 fn float_right_positioned_at_right_edge() {
5233 let mut doc = Document::new();
5234 let root = doc.root();
5235 let html = doc.create_element("html");
5236 let body = doc.create_element("body");
5237 let container = doc.create_element("div");
5238 let float_elem = doc.create_element("div");
5239 doc.append_child(root, html);
5240 doc.append_child(html, body);
5241 doc.append_child(body, container);
5242 doc.append_child(container, float_elem);
5243 doc.set_attribute(container, "style", "width: 400px;");
5244 doc.set_attribute(
5245 float_elem,
5246 "style",
5247 "float: right; width: 100px; height: 50px;",
5248 );
5249
5250 let tree = layout_doc(&doc);
5251 let body_box = &tree.root.children[0];
5252 let container_box = &body_box.children[0];
5253 let float_box = &container_box.children[0];
5254
5255 assert_eq!(float_box.float, Float::Right);
5256 // Float's right edge (content + padding + border + margin) should align
5257 // with the container's right content edge.
5258 let float_right = float_box.rect.x + float_box.rect.width;
5259 let container_right = container_box.rect.x + container_box.rect.width;
5260 assert!(
5261 (float_right - container_right).abs() < 0.01,
5262 "float right edge {} should match container right edge {}",
5263 float_right,
5264 container_right,
5265 );
5266 }
5267
5268 #[test]
5269 fn two_left_floats_stack_horizontally() {
5270 let mut doc = Document::new();
5271 let root = doc.root();
5272 let html = doc.create_element("html");
5273 let body = doc.create_element("body");
5274 let container = doc.create_element("div");
5275 let float1 = doc.create_element("div");
5276 let float2 = doc.create_element("div");
5277 doc.append_child(root, html);
5278 doc.append_child(html, body);
5279 doc.append_child(body, container);
5280 doc.append_child(container, float1);
5281 doc.append_child(container, float2);
5282 doc.set_attribute(container, "style", "width: 400px;");
5283 doc.set_attribute(float1, "style", "float: left; width: 100px; height: 50px;");
5284 doc.set_attribute(float2, "style", "float: left; width: 120px; height: 50px;");
5285
5286 let tree = layout_doc(&doc);
5287 let body_box = &tree.root.children[0];
5288 let container_box = &body_box.children[0];
5289 let f1 = &container_box.children[0];
5290 let f2 = &container_box.children[1];
5291
5292 // Second float should be placed to the right of the first.
5293 let f1_right =
5294 f1.rect.x + f1.rect.width + f1.padding.right + f1.border.right + f1.margin.right;
5295 assert!(
5296 f2.rect.x >= f1_right - 0.01,
5297 "second float x ({}) should be >= first float right edge ({})",
5298 f2.rect.x,
5299 f1_right,
5300 );
5301 // Both should be on the same row (same y).
5302 assert!(
5303 (f1.rect.y - f2.rect.y).abs() < 0.01,
5304 "floats should be on the same row: {} vs {}",
5305 f1.rect.y,
5306 f2.rect.y,
5307 );
5308 }
5309
5310 #[test]
5311 fn clear_both_moves_below_floats() {
5312 let mut doc = Document::new();
5313 let root = doc.root();
5314 let html = doc.create_element("html");
5315 let body = doc.create_element("body");
5316 let container = doc.create_element("div");
5317 let float_elem = doc.create_element("div");
5318 let cleared = doc.create_element("div");
5319 let text = doc.create_text("After clear");
5320 doc.append_child(root, html);
5321 doc.append_child(html, body);
5322 doc.append_child(body, container);
5323 doc.append_child(container, float_elem);
5324 doc.append_child(container, cleared);
5325 doc.append_child(cleared, text);
5326 doc.set_attribute(container, "style", "width: 400px;");
5327 doc.set_attribute(
5328 float_elem,
5329 "style",
5330 "float: left; width: 100px; height: 80px;",
5331 );
5332 doc.set_attribute(cleared, "style", "clear: both;");
5333
5334 let tree = layout_doc(&doc);
5335 let body_box = &tree.root.children[0];
5336 let container_box = &body_box.children[0];
5337
5338 // Find the cleared element (skip the float).
5339 let float_box = &container_box.children[0];
5340 let cleared_box = &container_box.children[1];
5341
5342 let float_bottom = float_box.rect.y
5343 + float_box.rect.height
5344 + float_box.padding.bottom
5345 + float_box.border.bottom
5346 + float_box.margin.bottom;
5347 assert!(
5348 cleared_box.rect.y >= float_bottom - 0.01,
5349 "cleared element y ({}) should be >= float bottom ({})",
5350 cleared_box.rect.y,
5351 float_bottom,
5352 );
5353 }
5354
5355 #[test]
5356 fn bfc_contains_floats() {
5357 // A container with overflow:hidden (establishes BFC) should expand
5358 // to contain its floated children.
5359 let mut doc = Document::new();
5360 let root = doc.root();
5361 let html = doc.create_element("html");
5362 let body = doc.create_element("body");
5363 let container = doc.create_element("div");
5364 let float_elem = doc.create_element("div");
5365 doc.append_child(root, html);
5366 doc.append_child(html, body);
5367 doc.append_child(body, container);
5368 doc.append_child(container, float_elem);
5369 doc.set_attribute(container, "style", "width: 400px; overflow: hidden;");
5370 doc.set_attribute(
5371 float_elem,
5372 "style",
5373 "float: left; width: 100px; height: 150px;",
5374 );
5375
5376 let tree = layout_doc(&doc);
5377 let body_box = &tree.root.children[0];
5378 let container_box = &body_box.children[0];
5379
5380 // Container should be at least 150px tall to contain the float.
5381 assert!(
5382 container_box.rect.height >= 150.0 - 0.01,
5383 "BFC container height ({}) should be >= float height (150)",
5384 container_box.rect.height,
5385 );
5386 }
5387
5388 #[test]
5389 fn inline_text_wraps_around_float() {
5390 // Text in a block should flow around a float.
5391 let mut doc = Document::new();
5392 let root = doc.root();
5393 let html = doc.create_element("html");
5394 let body = doc.create_element("body");
5395 let container = doc.create_element("div");
5396 let float_elem = doc.create_element("div");
5397 let text_p = doc.create_element("p");
5398 let text = doc.create_text("Some text content that wraps");
5399 doc.append_child(root, html);
5400 doc.append_child(html, body);
5401 doc.append_child(body, container);
5402 doc.append_child(container, float_elem);
5403 doc.append_child(container, text_p);
5404 doc.append_child(text_p, text);
5405 doc.set_attribute(container, "style", "width: 400px;");
5406 doc.set_attribute(
5407 float_elem,
5408 "style",
5409 "float: left; width: 100px; height: 80px;",
5410 );
5411
5412 let tree = layout_doc(&doc);
5413 let body_box = &tree.root.children[0];
5414 let container_box = &body_box.children[0];
5415
5416 // Find the text paragraph (it should be the second child, after the float).
5417 let text_box = &container_box.children[1];
5418
5419 // The text lines that overlap with the float should be offset to
5420 // the right of the float. Check that the first line's x is shifted.
5421 if !text_box.lines.is_empty() {
5422 let first_line = &text_box.lines[0];
5423 let float_box = &container_box.children[0];
5424 let float_right = float_box.rect.x
5425 + float_box.rect.width
5426 + float_box.padding.right
5427 + float_box.border.right
5428 + float_box.margin.right;
5429 assert!(
5430 first_line.x >= float_right - 0.01,
5431 "first line x ({}) should be >= float right edge ({})",
5432 first_line.x,
5433 float_right,
5434 );
5435 }
5436 }
5437
5438 #[test]
5439 fn bare_text_alongside_float_is_laid_out() {
5440 // When a container has only float children + bare text (no block
5441 // siblings), the text should still be wrapped in an anonymous block
5442 // and laid out correctly.
5443 let mut doc = Document::new();
5444 let root = doc.root();
5445 let html = doc.create_element("html");
5446 let body = doc.create_element("body");
5447 let container = doc.create_element("div");
5448 let float_elem = doc.create_element("div");
5449 let text = doc.create_text("Hello world");
5450 doc.append_child(root, html);
5451 doc.append_child(html, body);
5452 doc.append_child(body, container);
5453 doc.append_child(container, float_elem);
5454 doc.append_child(container, text);
5455 doc.set_attribute(container, "style", "width: 400px;");
5456 doc.set_attribute(
5457 float_elem,
5458 "style",
5459 "float: left; width: 100px; height: 50px;",
5460 );
5461
5462 let tree = layout_doc(&doc);
5463 let body_box = &tree.root.children[0];
5464 let container_box = &body_box.children[0];
5465
5466 // The text should be wrapped in an anonymous block and produce text lines.
5467 let has_text = container_box.children.iter().any(|c| !c.lines.is_empty());
5468 assert!(
5469 has_text,
5470 "bare text alongside a float should be laid out (found {} children, none with text lines)",
5471 container_box.children.len(),
5472 );
5473 }
5474}