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