we (web engine): Experimental web browser project to understand the limits of Claude
at flexbox 3979 lines 136 kB view raw
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}