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