we (web engine): Experimental web browser project to understand the limits of Claude
at box-sizing 2389 lines 80 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 BorderStyle, BoxSizing, ComputedStyle, Display, LengthOrAuto, Overflow, Position, StyledNode, 12 TextAlign, TextDecoration, 13}; 14use we_text::font::Font; 15 16/// Edge sizes for box model (margin, padding, border). 17#[derive(Debug, Clone, Copy, Default, PartialEq)] 18pub struct EdgeSizes { 19 pub top: f32, 20 pub right: f32, 21 pub bottom: f32, 22 pub left: f32, 23} 24 25/// A positioned rectangle with content area dimensions. 26#[derive(Debug, Clone, Copy, Default, PartialEq)] 27pub struct Rect { 28 pub x: f32, 29 pub y: f32, 30 pub width: f32, 31 pub height: f32, 32} 33 34/// The type of layout box. 35#[derive(Debug)] 36pub enum BoxType { 37 /// Block-level box from an element. 38 Block(NodeId), 39 /// Inline-level box from an element. 40 Inline(NodeId), 41 /// A run of text from a text node. 42 TextRun { node: NodeId, text: String }, 43 /// Anonymous block wrapping inline content within a block container. 44 Anonymous, 45} 46 47/// A single positioned text fragment with its own styling. 48/// 49/// Multiple fragments can share the same y-coordinate when they are 50/// on the same visual line (e.g. `<p>Hello <em>world</em></p>` produces 51/// two fragments at the same y). 52#[derive(Debug, Clone, PartialEq)] 53pub struct TextLine { 54 pub text: String, 55 pub x: f32, 56 pub y: f32, 57 pub width: f32, 58 pub font_size: f32, 59 pub color: Color, 60 pub text_decoration: TextDecoration, 61 pub background_color: Color, 62} 63 64/// A box in the layout tree with dimensions and child boxes. 65#[derive(Debug)] 66pub struct LayoutBox { 67 pub box_type: BoxType, 68 pub rect: Rect, 69 pub margin: EdgeSizes, 70 pub padding: EdgeSizes, 71 pub border: EdgeSizes, 72 pub children: Vec<LayoutBox>, 73 pub font_size: f32, 74 /// Positioned text fragments (populated for boxes with inline content). 75 pub lines: Vec<TextLine>, 76 /// Text color. 77 pub color: Color, 78 /// Background color. 79 pub background_color: Color, 80 /// Text decoration (underline, etc.). 81 pub text_decoration: TextDecoration, 82 /// Border styles (top, right, bottom, left). 83 pub border_styles: [BorderStyle; 4], 84 /// Border colors (top, right, bottom, left). 85 pub border_colors: [Color; 4], 86 /// Text alignment for this box's inline content. 87 pub text_align: TextAlign, 88 /// Computed line height in px. 89 pub line_height: f32, 90 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 91 pub replaced_size: Option<(f32, f32)>, 92 /// CSS `position` property. 93 pub position: Position, 94 /// Relative position offset (dx, dy) applied after normal flow layout. 95 pub relative_offset: (f32, f32), 96 /// CSS `overflow` property. 97 pub overflow: Overflow, 98 /// CSS `box-sizing` property. 99 pub box_sizing: BoxSizing, 100 /// CSS `width` property (explicit or auto). 101 pub css_width: LengthOrAuto, 102 /// CSS `height` property (explicit or auto). 103 pub css_height: LengthOrAuto, 104} 105 106impl LayoutBox { 107 fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 108 LayoutBox { 109 box_type, 110 rect: Rect::default(), 111 margin: EdgeSizes::default(), 112 padding: EdgeSizes::default(), 113 border: EdgeSizes::default(), 114 children: Vec::new(), 115 font_size: style.font_size, 116 lines: Vec::new(), 117 color: style.color, 118 background_color: style.background_color, 119 text_decoration: style.text_decoration, 120 border_styles: [ 121 style.border_top_style, 122 style.border_right_style, 123 style.border_bottom_style, 124 style.border_left_style, 125 ], 126 border_colors: [ 127 style.border_top_color, 128 style.border_right_color, 129 style.border_bottom_color, 130 style.border_left_color, 131 ], 132 text_align: style.text_align, 133 line_height: style.line_height, 134 replaced_size: None, 135 position: style.position, 136 relative_offset: (0.0, 0.0), 137 overflow: style.overflow, 138 box_sizing: style.box_sizing, 139 css_width: style.width, 140 css_height: style.height, 141 } 142 } 143 144 /// Total height including margin, border, and padding. 145 pub fn margin_box_height(&self) -> f32 { 146 self.margin.top 147 + self.border.top 148 + self.padding.top 149 + self.rect.height 150 + self.padding.bottom 151 + self.border.bottom 152 + self.margin.bottom 153 } 154 155 /// Iterate over all boxes in depth-first pre-order. 156 pub fn iter(&self) -> LayoutBoxIter<'_> { 157 LayoutBoxIter { stack: vec![self] } 158 } 159} 160 161/// Depth-first pre-order iterator over layout boxes. 162pub struct LayoutBoxIter<'a> { 163 stack: Vec<&'a LayoutBox>, 164} 165 166impl<'a> Iterator for LayoutBoxIter<'a> { 167 type Item = &'a LayoutBox; 168 169 fn next(&mut self) -> Option<&'a LayoutBox> { 170 let node = self.stack.pop()?; 171 for child in node.children.iter().rev() { 172 self.stack.push(child); 173 } 174 Some(node) 175 } 176} 177 178/// The result of laying out a document. 179#[derive(Debug)] 180pub struct LayoutTree { 181 pub root: LayoutBox, 182 pub width: f32, 183 pub height: f32, 184} 185 186impl LayoutTree { 187 /// Iterate over all layout boxes in depth-first pre-order. 188 pub fn iter(&self) -> LayoutBoxIter<'_> { 189 self.root.iter() 190 } 191} 192 193// --------------------------------------------------------------------------- 194// Resolve LengthOrAuto to f32 195// --------------------------------------------------------------------------- 196 197fn resolve_length(value: LengthOrAuto) -> f32 { 198 match value { 199 LengthOrAuto::Length(px) => px, 200 LengthOrAuto::Auto => 0.0, 201 } 202} 203 204/// Resolve horizontal offset for `position: relative`. 205/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). 206fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto) -> f32 { 207 match left { 208 LengthOrAuto::Length(px) => px, 209 LengthOrAuto::Auto => match right { 210 LengthOrAuto::Length(px) => -px, 211 LengthOrAuto::Auto => 0.0, 212 }, 213 } 214} 215 216/// Resolve vertical offset for `position: relative`. 217/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). 218fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto) -> f32 { 219 match top { 220 LengthOrAuto::Length(px) => px, 221 LengthOrAuto::Auto => match bottom { 222 LengthOrAuto::Length(px) => -px, 223 LengthOrAuto::Auto => 0.0, 224 }, 225 } 226} 227 228// --------------------------------------------------------------------------- 229// Build layout tree from styled tree 230// --------------------------------------------------------------------------- 231 232fn build_box( 233 styled: &StyledNode, 234 doc: &Document, 235 image_sizes: &HashMap<NodeId, (f32, f32)>, 236) -> Option<LayoutBox> { 237 let node = styled.node; 238 let style = &styled.style; 239 240 match doc.node_data(node) { 241 NodeData::Document => { 242 let mut children = Vec::new(); 243 for child in &styled.children { 244 if let Some(child_box) = build_box(child, doc, image_sizes) { 245 children.push(child_box); 246 } 247 } 248 if children.len() == 1 { 249 children.into_iter().next() 250 } else if children.is_empty() { 251 None 252 } else { 253 let mut b = LayoutBox::new(BoxType::Anonymous, style); 254 b.children = children; 255 Some(b) 256 } 257 } 258 NodeData::Element { .. } => { 259 if style.display == Display::None { 260 return None; 261 } 262 263 let margin = EdgeSizes { 264 top: resolve_length(style.margin_top), 265 right: resolve_length(style.margin_right), 266 bottom: resolve_length(style.margin_bottom), 267 left: resolve_length(style.margin_left), 268 }; 269 let padding = EdgeSizes { 270 top: style.padding_top, 271 right: style.padding_right, 272 bottom: style.padding_bottom, 273 left: style.padding_left, 274 }; 275 let border = EdgeSizes { 276 top: if style.border_top_style != BorderStyle::None { 277 style.border_top_width 278 } else { 279 0.0 280 }, 281 right: if style.border_right_style != BorderStyle::None { 282 style.border_right_width 283 } else { 284 0.0 285 }, 286 bottom: if style.border_bottom_style != BorderStyle::None { 287 style.border_bottom_width 288 } else { 289 0.0 290 }, 291 left: if style.border_left_style != BorderStyle::None { 292 style.border_left_width 293 } else { 294 0.0 295 }, 296 }; 297 298 let mut children = Vec::new(); 299 for child in &styled.children { 300 if let Some(child_box) = build_box(child, doc, image_sizes) { 301 children.push(child_box); 302 } 303 } 304 305 let box_type = match style.display { 306 Display::Block => BoxType::Block(node), 307 Display::Inline => BoxType::Inline(node), 308 Display::None => unreachable!(), 309 }; 310 311 if style.display == Display::Block { 312 children = normalize_children(children, style); 313 } 314 315 let mut b = LayoutBox::new(box_type, style); 316 b.margin = margin; 317 b.padding = padding; 318 b.border = border; 319 b.children = children; 320 321 // Check for replaced element (e.g., <img>). 322 if let Some(&(w, h)) = image_sizes.get(&node) { 323 b.replaced_size = Some((w, h)); 324 } 325 326 // Compute relative position offset. 327 if style.position == Position::Relative { 328 let dx = resolve_relative_horizontal(style.left, style.right); 329 let dy = resolve_relative_vertical(style.top, style.bottom); 330 b.relative_offset = (dx, dy); 331 } 332 333 Some(b) 334 } 335 NodeData::Text { data } => { 336 let collapsed = collapse_whitespace(data); 337 if collapsed.is_empty() { 338 return None; 339 } 340 Some(LayoutBox::new( 341 BoxType::TextRun { 342 node, 343 text: collapsed, 344 }, 345 style, 346 )) 347 } 348 NodeData::Comment { .. } => None, 349 } 350} 351 352/// Collapse runs of whitespace to a single space. 353fn collapse_whitespace(s: &str) -> String { 354 let mut result = String::new(); 355 let mut in_ws = false; 356 for ch in s.chars() { 357 if ch.is_whitespace() { 358 if !in_ws { 359 result.push(' '); 360 } 361 in_ws = true; 362 } else { 363 in_ws = false; 364 result.push(ch); 365 } 366 } 367 result 368} 369 370/// If a block container has a mix of block-level and inline-level children, 371/// wrap consecutive inline runs in anonymous block boxes. 372fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> { 373 if children.is_empty() { 374 return children; 375 } 376 377 let has_block = children.iter().any(is_block_level); 378 if !has_block { 379 return children; 380 } 381 382 let has_inline = children.iter().any(|c| !is_block_level(c)); 383 if !has_inline { 384 return children; 385 } 386 387 let mut result = Vec::new(); 388 let mut inline_group: Vec<LayoutBox> = Vec::new(); 389 390 for child in children { 391 if is_block_level(&child) { 392 if !inline_group.is_empty() { 393 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 394 anon.children = std::mem::take(&mut inline_group); 395 result.push(anon); 396 } 397 result.push(child); 398 } else { 399 inline_group.push(child); 400 } 401 } 402 403 if !inline_group.is_empty() { 404 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 405 anon.children = inline_group; 406 result.push(anon); 407 } 408 409 result 410} 411 412fn is_block_level(b: &LayoutBox) -> bool { 413 matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) 414} 415 416// --------------------------------------------------------------------------- 417// Layout algorithm 418// --------------------------------------------------------------------------- 419 420/// Position and size a layout box within `available_width` at position (`x`, `y`). 421fn compute_layout( 422 b: &mut LayoutBox, 423 x: f32, 424 y: f32, 425 available_width: f32, 426 font: &Font, 427 doc: &Document, 428) { 429 let content_x = x + b.margin.left + b.border.left + b.padding.left; 430 let content_y = y + b.margin.top + b.border.top + b.padding.top; 431 432 let horizontal_extra = b.border.left + b.border.right + b.padding.left + b.padding.right; 433 434 // Resolve content width: explicit CSS width (adjusted for box-sizing) or auto. 435 let content_width = match b.css_width { 436 LengthOrAuto::Length(w) => match b.box_sizing { 437 BoxSizing::ContentBox => w.max(0.0), 438 BoxSizing::BorderBox => (w - horizontal_extra).max(0.0), 439 }, 440 LengthOrAuto::Auto => { 441 (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0) 442 } 443 }; 444 445 b.rect.x = content_x; 446 b.rect.y = content_y; 447 b.rect.width = content_width; 448 449 // Replaced elements (e.g., <img>) have intrinsic dimensions. 450 if let Some((rw, rh)) = b.replaced_size { 451 // Use CSS width/height if specified, otherwise use replaced dimensions. 452 // Content width is the minimum of replaced width and available width. 453 b.rect.width = rw.min(content_width); 454 b.rect.height = rh; 455 apply_relative_offset(b); 456 return; 457 } 458 459 match &b.box_type { 460 BoxType::Block(_) | BoxType::Anonymous => { 461 if has_block_children(b) { 462 layout_block_children(b, font, doc); 463 } else { 464 layout_inline_children(b, font, doc); 465 } 466 } 467 BoxType::TextRun { .. } | BoxType::Inline(_) => { 468 // Handled by the parent's inline layout. 469 } 470 } 471 472 // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. 473 if let LengthOrAuto::Length(h) = b.css_height { 474 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 475 b.rect.height = match b.box_sizing { 476 BoxSizing::ContentBox => h.max(0.0), 477 BoxSizing::BorderBox => (h - vertical_extra).max(0.0), 478 }; 479 } 480 481 apply_relative_offset(b); 482} 483 484/// Apply `position: relative` offset to a box and all its descendants. 485/// 486/// This shifts the visual position without affecting the normal-flow layout 487/// of surrounding elements (the original space is preserved). 488fn apply_relative_offset(b: &mut LayoutBox) { 489 let (dx, dy) = b.relative_offset; 490 if dx == 0.0 && dy == 0.0 { 491 return; 492 } 493 shift_box(b, dx, dy); 494} 495 496/// Recursively shift a box and all its descendants by (dx, dy). 497fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { 498 b.rect.x += dx; 499 b.rect.y += dy; 500 for line in &mut b.lines { 501 line.x += dx; 502 line.y += dy; 503 } 504 for child in &mut b.children { 505 shift_box(child, dx, dy); 506 } 507} 508 509fn has_block_children(b: &LayoutBox) -> bool { 510 b.children.iter().any(is_block_level) 511} 512 513/// Collapse two adjoining margins per CSS2 §8.3.1. 514/// 515/// Both non-negative → use the larger. 516/// Both negative → use the more negative. 517/// Mixed → sum the largest positive and most negative. 518fn collapse_margins(a: f32, b: f32) -> f32 { 519 if a >= 0.0 && b >= 0.0 { 520 a.max(b) 521 } else if a < 0.0 && b < 0.0 { 522 a.min(b) 523 } else { 524 a + b 525 } 526} 527 528/// Returns `true` if this box establishes a new block formatting context, 529/// which prevents its margins from collapsing with children. 530fn establishes_bfc(b: &LayoutBox) -> bool { 531 b.overflow != Overflow::Visible 532} 533 534/// Returns `true` if a block box has no in-flow content (empty block). 535fn is_empty_block(b: &LayoutBox) -> bool { 536 b.children.is_empty() && b.lines.is_empty() && b.replaced_size.is_none() 537} 538 539/// Pre-collapse parent-child margins (CSS2 §8.3.1). 540/// 541/// When a parent has no border/padding/BFC separating it from its first/last 542/// child, the child's margin collapses into the parent's margin. This must 543/// happen *before* positioning so the parent is placed using the collapsed 544/// value. The function walks bottom-up: children are pre-collapsed first, then 545/// their (possibly enlarged) margins are folded into the parent. 546fn pre_collapse_margins(b: &mut LayoutBox) { 547 // Recurse into block children first (bottom-up). 548 for child in &mut b.children { 549 if is_block_level(child) { 550 pre_collapse_margins(child); 551 } 552 } 553 554 if !matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) { 555 return; 556 } 557 if establishes_bfc(b) { 558 return; 559 } 560 if !has_block_children(b) { 561 return; 562 } 563 564 // --- Top: collapse with first non-empty child --- 565 if b.border.top == 0.0 && b.padding.top == 0.0 { 566 if let Some(child_top) = first_block_top_margin(&b.children) { 567 b.margin.top = collapse_margins(b.margin.top, child_top); 568 } 569 } 570 571 // --- Bottom: collapse with last non-empty child --- 572 if b.border.bottom == 0.0 && b.padding.bottom == 0.0 { 573 if let Some(child_bottom) = last_block_bottom_margin(&b.children) { 574 b.margin.bottom = collapse_margins(b.margin.bottom, child_bottom); 575 } 576 } 577} 578 579/// Top margin of the first non-empty block child (already pre-collapsed). 580fn first_block_top_margin(children: &[LayoutBox]) -> Option<f32> { 581 for child in children { 582 if is_block_level(child) { 583 if is_empty_block(child) { 584 continue; 585 } 586 return Some(child.margin.top); 587 } 588 } 589 // All block children empty — fold all their collapsed margins. 590 let mut m = 0.0f32; 591 for child in children.iter().filter(|c| is_block_level(c)) { 592 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 593 } 594 if m != 0.0 { 595 Some(m) 596 } else { 597 None 598 } 599} 600 601/// Bottom margin of the last non-empty block child (already pre-collapsed). 602fn last_block_bottom_margin(children: &[LayoutBox]) -> Option<f32> { 603 for child in children.iter().rev() { 604 if is_block_level(child) { 605 if is_empty_block(child) { 606 continue; 607 } 608 return Some(child.margin.bottom); 609 } 610 } 611 let mut m = 0.0f32; 612 for child in children.iter().filter(|c| is_block_level(c)) { 613 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 614 } 615 if m != 0.0 { 616 Some(m) 617 } else { 618 None 619 } 620} 621 622/// Lay out block-level children with vertical margin collapsing (CSS2 §8.3.1). 623/// 624/// Handles adjacent-sibling collapsing, empty-block collapsing, and 625/// parent-child internal spacing (the parent's external margins were already 626/// updated by `pre_collapse_margins`). 627fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 628 let content_x = parent.rect.x; 629 let content_width = parent.rect.width; 630 let mut cursor_y = parent.rect.y; 631 632 let parent_top_open = 633 parent.border.top == 0.0 && parent.padding.top == 0.0 && !establishes_bfc(parent); 634 let parent_bottom_open = 635 parent.border.bottom == 0.0 && parent.padding.bottom == 0.0 && !establishes_bfc(parent); 636 637 // Pending bottom margin from the previous sibling. 638 let mut pending_margin: Option<f32> = None; 639 let child_count = parent.children.len(); 640 641 for i in 0..child_count { 642 let child_top_margin = parent.children[i].margin.top; 643 let child_bottom_margin = parent.children[i].margin.bottom; 644 645 // --- Empty block: top+bottom margins self-collapse --- 646 if is_empty_block(&parent.children[i]) { 647 let self_collapsed = collapse_margins(child_top_margin, child_bottom_margin); 648 pending_margin = Some(match pending_margin { 649 Some(prev) => collapse_margins(prev, self_collapsed), 650 None => self_collapsed, 651 }); 652 // Position at cursor_y with zero height. 653 let child = &mut parent.children[i]; 654 child.rect.x = content_x + child.border.left + child.padding.left; 655 child.rect.y = cursor_y + child.border.top + child.padding.top; 656 child.rect.width = (content_width 657 - child.border.left 658 - child.border.right 659 - child.padding.left 660 - child.padding.right) 661 .max(0.0); 662 child.rect.height = 0.0; 663 continue; 664 } 665 666 // --- Compute effective top spacing --- 667 let collapsed_top = if let Some(prev_bottom) = pending_margin.take() { 668 // Sibling collapsing: previous bottom vs this top. 669 collapse_margins(prev_bottom, child_top_margin) 670 } else if i == 0 && parent_top_open { 671 // First child, parent top open: margin was already pulled into 672 // parent by pre_collapse_margins — no internal spacing. 673 0.0 674 } else { 675 child_top_margin 676 }; 677 678 // `compute_layout` adds `child.margin.top` internally, so compensate. 679 let y_for_child = cursor_y + collapsed_top - child_top_margin; 680 compute_layout( 681 &mut parent.children[i], 682 content_x, 683 y_for_child, 684 content_width, 685 font, 686 doc, 687 ); 688 689 let child = &parent.children[i]; 690 // Use the normal-flow position (before relative offset) so that 691 // `position: relative` does not affect sibling placement. 692 let (_, rel_dy) = child.relative_offset; 693 cursor_y = (child.rect.y - rel_dy) 694 + child.rect.height 695 + child.padding.bottom 696 + child.border.bottom; 697 pending_margin = Some(child_bottom_margin); 698 } 699 700 // Trailing margin. 701 if let Some(trailing) = pending_margin { 702 if !parent_bottom_open { 703 // Parent has border/padding at bottom — margin stays inside. 704 cursor_y += trailing; 705 } 706 // If parent_bottom_open, the margin was already pulled into the 707 // parent by pre_collapse_margins. 708 } 709 710 parent.rect.height = cursor_y - parent.rect.y; 711} 712 713// --------------------------------------------------------------------------- 714// Inline formatting context 715// --------------------------------------------------------------------------- 716 717/// An inline item produced by flattening the inline tree. 718enum InlineItemKind { 719 /// A word of text with associated styling. 720 Word { 721 text: String, 722 font_size: f32, 723 color: Color, 724 text_decoration: TextDecoration, 725 background_color: Color, 726 }, 727 /// Whitespace between words. 728 Space { font_size: f32 }, 729 /// Forced line break (`<br>`). 730 ForcedBreak, 731 /// Start of an inline box (for margin/padding/border tracking). 732 InlineStart { 733 margin_left: f32, 734 padding_left: f32, 735 border_left: f32, 736 }, 737 /// End of an inline box. 738 InlineEnd { 739 margin_right: f32, 740 padding_right: f32, 741 border_right: f32, 742 }, 743} 744 745/// A pending fragment on the current line. 746struct PendingFragment { 747 text: String, 748 x: f32, 749 width: f32, 750 font_size: f32, 751 color: Color, 752 text_decoration: TextDecoration, 753 background_color: Color, 754} 755 756/// Flatten the inline children tree into a sequence of items. 757fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec<InlineItemKind>) { 758 for child in children { 759 match &child.box_type { 760 BoxType::TextRun { text, .. } => { 761 let words = split_into_words(text); 762 for segment in words { 763 match segment { 764 WordSegment::Word(w) => { 765 items.push(InlineItemKind::Word { 766 text: w, 767 font_size: child.font_size, 768 color: child.color, 769 text_decoration: child.text_decoration, 770 background_color: child.background_color, 771 }); 772 } 773 WordSegment::Space => { 774 items.push(InlineItemKind::Space { 775 font_size: child.font_size, 776 }); 777 } 778 } 779 } 780 } 781 BoxType::Inline(node_id) => { 782 if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { 783 if tag_name == "br" { 784 items.push(InlineItemKind::ForcedBreak); 785 continue; 786 } 787 } 788 789 items.push(InlineItemKind::InlineStart { 790 margin_left: child.margin.left, 791 padding_left: child.padding.left, 792 border_left: child.border.left, 793 }); 794 795 flatten_inline_tree(&child.children, doc, items); 796 797 items.push(InlineItemKind::InlineEnd { 798 margin_right: child.margin.right, 799 padding_right: child.padding.right, 800 border_right: child.border.right, 801 }); 802 } 803 _ => {} 804 } 805 } 806} 807 808enum WordSegment { 809 Word(String), 810 Space, 811} 812 813/// Split text into alternating words and spaces. 814fn split_into_words(text: &str) -> Vec<WordSegment> { 815 let mut segments = Vec::new(); 816 let mut current_word = String::new(); 817 818 for ch in text.chars() { 819 if ch == ' ' { 820 if !current_word.is_empty() { 821 segments.push(WordSegment::Word(std::mem::take(&mut current_word))); 822 } 823 segments.push(WordSegment::Space); 824 } else { 825 current_word.push(ch); 826 } 827 } 828 829 if !current_word.is_empty() { 830 segments.push(WordSegment::Word(current_word)); 831 } 832 833 segments 834} 835 836/// Lay out inline children using a proper inline formatting context. 837fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 838 let available_width = parent.rect.width; 839 let text_align = parent.text_align; 840 let line_height = parent.line_height; 841 842 let mut items = Vec::new(); 843 flatten_inline_tree(&parent.children, doc, &mut items); 844 845 if items.is_empty() { 846 parent.rect.height = 0.0; 847 return; 848 } 849 850 // Process items into line boxes. 851 let mut all_lines: Vec<Vec<PendingFragment>> = Vec::new(); 852 let mut current_line: Vec<PendingFragment> = Vec::new(); 853 let mut cursor_x: f32 = 0.0; 854 855 for item in &items { 856 match item { 857 InlineItemKind::Word { 858 text, 859 font_size, 860 color, 861 text_decoration, 862 background_color, 863 } => { 864 let word_width = measure_text_width(font, text, *font_size); 865 866 // If this word doesn't fit and the line isn't empty, break. 867 if cursor_x > 0.0 && cursor_x + word_width > available_width { 868 all_lines.push(std::mem::take(&mut current_line)); 869 cursor_x = 0.0; 870 } 871 872 current_line.push(PendingFragment { 873 text: text.clone(), 874 x: cursor_x, 875 width: word_width, 876 font_size: *font_size, 877 color: *color, 878 text_decoration: *text_decoration, 879 background_color: *background_color, 880 }); 881 cursor_x += word_width; 882 } 883 InlineItemKind::Space { font_size } => { 884 // Only add space if we have content on the line. 885 if !current_line.is_empty() { 886 let space_width = measure_text_width(font, " ", *font_size); 887 if cursor_x + space_width <= available_width { 888 cursor_x += space_width; 889 } 890 } 891 } 892 InlineItemKind::ForcedBreak => { 893 all_lines.push(std::mem::take(&mut current_line)); 894 cursor_x = 0.0; 895 } 896 InlineItemKind::InlineStart { 897 margin_left, 898 padding_left, 899 border_left, 900 } => { 901 cursor_x += margin_left + padding_left + border_left; 902 } 903 InlineItemKind::InlineEnd { 904 margin_right, 905 padding_right, 906 border_right, 907 } => { 908 cursor_x += margin_right + padding_right + border_right; 909 } 910 } 911 } 912 913 // Flush the last line. 914 if !current_line.is_empty() { 915 all_lines.push(current_line); 916 } 917 918 if all_lines.is_empty() { 919 parent.rect.height = 0.0; 920 return; 921 } 922 923 // Position lines vertically and apply text-align. 924 let mut text_lines = Vec::new(); 925 let mut y = parent.rect.y; 926 let num_lines = all_lines.len(); 927 928 for (line_idx, line_fragments) in all_lines.iter().enumerate() { 929 if line_fragments.is_empty() { 930 y += line_height; 931 continue; 932 } 933 934 // Compute line width from last fragment. 935 let line_width = match line_fragments.last() { 936 Some(last) => last.x + last.width, 937 None => 0.0, 938 }; 939 940 // Compute text-align offset. 941 let is_last_line = line_idx == num_lines - 1; 942 let align_offset = 943 compute_align_offset(text_align, available_width, line_width, is_last_line); 944 945 for frag in line_fragments { 946 text_lines.push(TextLine { 947 text: frag.text.clone(), 948 x: parent.rect.x + frag.x + align_offset, 949 y, 950 width: frag.width, 951 font_size: frag.font_size, 952 color: frag.color, 953 text_decoration: frag.text_decoration, 954 background_color: frag.background_color, 955 }); 956 } 957 958 y += line_height; 959 } 960 961 parent.rect.height = num_lines as f32 * line_height; 962 parent.lines = text_lines; 963} 964 965/// Compute the horizontal offset for text alignment. 966fn compute_align_offset( 967 align: TextAlign, 968 available_width: f32, 969 line_width: f32, 970 is_last_line: bool, 971) -> f32 { 972 let extra_space = (available_width - line_width).max(0.0); 973 match align { 974 TextAlign::Left => 0.0, 975 TextAlign::Center => extra_space / 2.0, 976 TextAlign::Right => extra_space, 977 TextAlign::Justify => { 978 // Don't justify the last line (CSS spec behavior). 979 if is_last_line { 980 0.0 981 } else { 982 // For justify, we shift the whole line by 0 — the actual distribution 983 // of space between words would need per-word spacing. For now, treat 984 // as left-aligned; full justify support is a future enhancement. 985 0.0 986 } 987 } 988 } 989} 990 991// --------------------------------------------------------------------------- 992// Text measurement 993// --------------------------------------------------------------------------- 994 995/// Measure the total advance width of a text string at the given font size. 996fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { 997 let shaped = font.shape_text(text, font_size); 998 match shaped.last() { 999 Some(last) => last.x_offset + last.x_advance, 1000 None => 0.0, 1001 } 1002} 1003 1004// --------------------------------------------------------------------------- 1005// Public API 1006// --------------------------------------------------------------------------- 1007 1008/// Build and lay out from a styled tree (produced by `resolve_styles`). 1009/// 1010/// Returns a `LayoutTree` with positioned boxes ready for rendering. 1011pub fn layout( 1012 styled_root: &StyledNode, 1013 doc: &Document, 1014 viewport_width: f32, 1015 _viewport_height: f32, 1016 font: &Font, 1017 image_sizes: &HashMap<NodeId, (f32, f32)>, 1018) -> LayoutTree { 1019 let mut root = match build_box(styled_root, doc, image_sizes) { 1020 Some(b) => b, 1021 None => { 1022 return LayoutTree { 1023 root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()), 1024 width: viewport_width, 1025 height: 0.0, 1026 }; 1027 } 1028 }; 1029 1030 // Pre-collapse parent-child margins before positioning. 1031 pre_collapse_margins(&mut root); 1032 1033 compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc); 1034 1035 let height = root.margin_box_height(); 1036 LayoutTree { 1037 root, 1038 width: viewport_width, 1039 height, 1040 } 1041} 1042 1043#[cfg(test)] 1044mod tests { 1045 use super::*; 1046 use we_dom::Document; 1047 use we_style::computed::{extract_stylesheets, resolve_styles}; 1048 1049 fn test_font() -> Font { 1050 let paths = [ 1051 "/System/Library/Fonts/Geneva.ttf", 1052 "/System/Library/Fonts/Monaco.ttf", 1053 ]; 1054 for path in &paths { 1055 let p = std::path::Path::new(path); 1056 if p.exists() { 1057 return Font::from_file(p).expect("failed to parse font"); 1058 } 1059 } 1060 panic!("no test font found"); 1061 } 1062 1063 fn layout_doc(doc: &Document) -> LayoutTree { 1064 let font = test_font(); 1065 let sheets = extract_stylesheets(doc); 1066 let styled = resolve_styles(doc, &sheets).unwrap(); 1067 layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) 1068 } 1069 1070 #[test] 1071 fn empty_document() { 1072 let doc = Document::new(); 1073 let font = test_font(); 1074 let sheets = extract_stylesheets(&doc); 1075 let styled = resolve_styles(&doc, &sheets); 1076 if let Some(styled) = styled { 1077 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1078 assert_eq!(tree.width, 800.0); 1079 } 1080 } 1081 1082 #[test] 1083 fn single_paragraph() { 1084 let mut doc = Document::new(); 1085 let root = doc.root(); 1086 let html = doc.create_element("html"); 1087 let body = doc.create_element("body"); 1088 let p = doc.create_element("p"); 1089 let text = doc.create_text("Hello world"); 1090 doc.append_child(root, html); 1091 doc.append_child(html, body); 1092 doc.append_child(body, p); 1093 doc.append_child(p, text); 1094 1095 let tree = layout_doc(&doc); 1096 1097 assert!(matches!(tree.root.box_type, BoxType::Block(_))); 1098 1099 let body_box = &tree.root.children[0]; 1100 assert!(matches!(body_box.box_type, BoxType::Block(_))); 1101 1102 let p_box = &body_box.children[0]; 1103 assert!(matches!(p_box.box_type, BoxType::Block(_))); 1104 1105 assert!(!p_box.lines.is_empty(), "p should have text fragments"); 1106 1107 // Collect all text on the first visual line. 1108 let first_y = p_box.lines[0].y; 1109 let line_text: String = p_box 1110 .lines 1111 .iter() 1112 .filter(|l| (l.y - first_y).abs() < 0.01) 1113 .map(|l| l.text.as_str()) 1114 .collect::<Vec<_>>() 1115 .join(" "); 1116 assert!( 1117 line_text.contains("Hello") && line_text.contains("world"), 1118 "line should contain Hello and world, got: {line_text}" 1119 ); 1120 1121 assert_eq!(p_box.margin.top, 16.0); 1122 assert_eq!(p_box.margin.bottom, 16.0); 1123 } 1124 1125 #[test] 1126 fn blocks_stack_vertically() { 1127 let mut doc = Document::new(); 1128 let root = doc.root(); 1129 let html = doc.create_element("html"); 1130 let body = doc.create_element("body"); 1131 let p1 = doc.create_element("p"); 1132 let t1 = doc.create_text("First"); 1133 let p2 = doc.create_element("p"); 1134 let t2 = doc.create_text("Second"); 1135 doc.append_child(root, html); 1136 doc.append_child(html, body); 1137 doc.append_child(body, p1); 1138 doc.append_child(p1, t1); 1139 doc.append_child(body, p2); 1140 doc.append_child(p2, t2); 1141 1142 let tree = layout_doc(&doc); 1143 let body_box = &tree.root.children[0]; 1144 let first = &body_box.children[0]; 1145 let second = &body_box.children[1]; 1146 1147 assert!( 1148 second.rect.y > first.rect.y, 1149 "second p (y={}) should be below first p (y={})", 1150 second.rect.y, 1151 first.rect.y 1152 ); 1153 } 1154 1155 #[test] 1156 fn heading_larger_than_body() { 1157 let mut doc = Document::new(); 1158 let root = doc.root(); 1159 let html = doc.create_element("html"); 1160 let body = doc.create_element("body"); 1161 let h1 = doc.create_element("h1"); 1162 let h1_text = doc.create_text("Title"); 1163 let p = doc.create_element("p"); 1164 let p_text = doc.create_text("Text"); 1165 doc.append_child(root, html); 1166 doc.append_child(html, body); 1167 doc.append_child(body, h1); 1168 doc.append_child(h1, h1_text); 1169 doc.append_child(body, p); 1170 doc.append_child(p, p_text); 1171 1172 let tree = layout_doc(&doc); 1173 let body_box = &tree.root.children[0]; 1174 let h1_box = &body_box.children[0]; 1175 let p_box = &body_box.children[1]; 1176 1177 assert!( 1178 h1_box.font_size > p_box.font_size, 1179 "h1 font_size ({}) should be > p font_size ({})", 1180 h1_box.font_size, 1181 p_box.font_size 1182 ); 1183 assert_eq!(h1_box.font_size, 32.0); 1184 1185 assert!( 1186 h1_box.rect.height > p_box.rect.height, 1187 "h1 height ({}) should be > p height ({})", 1188 h1_box.rect.height, 1189 p_box.rect.height 1190 ); 1191 } 1192 1193 #[test] 1194 fn body_has_default_margin() { 1195 let mut doc = Document::new(); 1196 let root = doc.root(); 1197 let html = doc.create_element("html"); 1198 let body = doc.create_element("body"); 1199 let p = doc.create_element("p"); 1200 let text = doc.create_text("Test"); 1201 doc.append_child(root, html); 1202 doc.append_child(html, body); 1203 doc.append_child(body, p); 1204 doc.append_child(p, text); 1205 1206 let tree = layout_doc(&doc); 1207 let body_box = &tree.root.children[0]; 1208 1209 // body default margin is 8px, but it collapses with p's 16px margin 1210 // (parent-child collapsing: no border/padding on body). 1211 assert_eq!(body_box.margin.top, 16.0); 1212 assert_eq!(body_box.margin.right, 8.0); 1213 assert_eq!(body_box.margin.bottom, 16.0); 1214 assert_eq!(body_box.margin.left, 8.0); 1215 1216 assert_eq!(body_box.rect.x, 8.0); 1217 // body.rect.y = collapsed margin (16) from viewport top. 1218 assert_eq!(body_box.rect.y, 16.0); 1219 } 1220 1221 #[test] 1222 fn text_wraps_at_container_width() { 1223 let mut doc = Document::new(); 1224 let root = doc.root(); 1225 let html = doc.create_element("html"); 1226 let body = doc.create_element("body"); 1227 let p = doc.create_element("p"); 1228 let text = 1229 doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); 1230 doc.append_child(root, html); 1231 doc.append_child(html, body); 1232 doc.append_child(body, p); 1233 doc.append_child(p, text); 1234 1235 let font = test_font(); 1236 let sheets = extract_stylesheets(&doc); 1237 let styled = resolve_styles(&doc, &sheets).unwrap(); 1238 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 1239 let body_box = &tree.root.children[0]; 1240 let p_box = &body_box.children[0]; 1241 1242 // Count distinct y-positions to count visual lines. 1243 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1244 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1245 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1246 1247 assert!( 1248 ys.len() > 1, 1249 "text should wrap to multiple lines, got {} visual lines", 1250 ys.len() 1251 ); 1252 } 1253 1254 #[test] 1255 fn layout_produces_positive_dimensions() { 1256 let mut doc = Document::new(); 1257 let root = doc.root(); 1258 let html = doc.create_element("html"); 1259 let body = doc.create_element("body"); 1260 let div = doc.create_element("div"); 1261 let text = doc.create_text("Content"); 1262 doc.append_child(root, html); 1263 doc.append_child(html, body); 1264 doc.append_child(body, div); 1265 doc.append_child(div, text); 1266 1267 let tree = layout_doc(&doc); 1268 1269 for b in tree.iter() { 1270 assert!(b.rect.width >= 0.0, "width should be >= 0"); 1271 assert!(b.rect.height >= 0.0, "height should be >= 0"); 1272 } 1273 1274 assert!(tree.height > 0.0, "layout height should be > 0"); 1275 } 1276 1277 #[test] 1278 fn head_is_hidden() { 1279 let mut doc = Document::new(); 1280 let root = doc.root(); 1281 let html = doc.create_element("html"); 1282 let head = doc.create_element("head"); 1283 let title = doc.create_element("title"); 1284 let title_text = doc.create_text("Page Title"); 1285 let body = doc.create_element("body"); 1286 let p = doc.create_element("p"); 1287 let p_text = doc.create_text("Visible"); 1288 doc.append_child(root, html); 1289 doc.append_child(html, head); 1290 doc.append_child(head, title); 1291 doc.append_child(title, title_text); 1292 doc.append_child(html, body); 1293 doc.append_child(body, p); 1294 doc.append_child(p, p_text); 1295 1296 let tree = layout_doc(&doc); 1297 1298 assert_eq!( 1299 tree.root.children.len(), 1300 1, 1301 "html should have 1 child (body), head should be hidden" 1302 ); 1303 } 1304 1305 #[test] 1306 fn mixed_block_and_inline() { 1307 let mut doc = Document::new(); 1308 let root = doc.root(); 1309 let html = doc.create_element("html"); 1310 let body = doc.create_element("body"); 1311 let div = doc.create_element("div"); 1312 let text1 = doc.create_text("Text"); 1313 let p = doc.create_element("p"); 1314 let p_text = doc.create_text("Block"); 1315 let text2 = doc.create_text("More"); 1316 doc.append_child(root, html); 1317 doc.append_child(html, body); 1318 doc.append_child(body, div); 1319 doc.append_child(div, text1); 1320 doc.append_child(div, p); 1321 doc.append_child(p, p_text); 1322 doc.append_child(div, text2); 1323 1324 let tree = layout_doc(&doc); 1325 let body_box = &tree.root.children[0]; 1326 let div_box = &body_box.children[0]; 1327 1328 assert_eq!( 1329 div_box.children.len(), 1330 3, 1331 "div should have 3 children (anon, block, anon), got {}", 1332 div_box.children.len() 1333 ); 1334 1335 assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); 1336 assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); 1337 assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); 1338 } 1339 1340 #[test] 1341 fn inline_elements_contribute_text() { 1342 let mut doc = Document::new(); 1343 let root = doc.root(); 1344 let html = doc.create_element("html"); 1345 let body = doc.create_element("body"); 1346 let p = doc.create_element("p"); 1347 let t1 = doc.create_text("Hello "); 1348 let em = doc.create_element("em"); 1349 let t2 = doc.create_text("world"); 1350 let t3 = doc.create_text("!"); 1351 doc.append_child(root, html); 1352 doc.append_child(html, body); 1353 doc.append_child(body, p); 1354 doc.append_child(p, t1); 1355 doc.append_child(p, em); 1356 doc.append_child(em, t2); 1357 doc.append_child(p, t3); 1358 1359 let tree = layout_doc(&doc); 1360 let body_box = &tree.root.children[0]; 1361 let p_box = &body_box.children[0]; 1362 1363 assert!(!p_box.lines.is_empty()); 1364 1365 let first_y = p_box.lines[0].y; 1366 let line_texts: Vec<&str> = p_box 1367 .lines 1368 .iter() 1369 .filter(|l| (l.y - first_y).abs() < 0.01) 1370 .map(|l| l.text.as_str()) 1371 .collect(); 1372 let combined = line_texts.join(""); 1373 assert!( 1374 combined.contains("Hello") && combined.contains("world") && combined.contains("!"), 1375 "line should contain all text, got: {combined}" 1376 ); 1377 } 1378 1379 #[test] 1380 fn collapse_whitespace_works() { 1381 assert_eq!(collapse_whitespace("hello world"), "hello world"); 1382 assert_eq!(collapse_whitespace(" spaces "), " spaces "); 1383 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 1384 assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 1385 assert_eq!(collapse_whitespace(" "), " "); 1386 } 1387 1388 #[test] 1389 fn content_width_respects_body_margin() { 1390 let mut doc = Document::new(); 1391 let root = doc.root(); 1392 let html = doc.create_element("html"); 1393 let body = doc.create_element("body"); 1394 let div = doc.create_element("div"); 1395 let text = doc.create_text("Content"); 1396 doc.append_child(root, html); 1397 doc.append_child(html, body); 1398 doc.append_child(body, div); 1399 doc.append_child(div, text); 1400 1401 let font = test_font(); 1402 let sheets = extract_stylesheets(&doc); 1403 let styled = resolve_styles(&doc, &sheets).unwrap(); 1404 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1405 let body_box = &tree.root.children[0]; 1406 1407 assert_eq!(body_box.rect.width, 784.0); 1408 1409 let div_box = &body_box.children[0]; 1410 assert_eq!(div_box.rect.width, 784.0); 1411 } 1412 1413 #[test] 1414 fn multiple_heading_levels() { 1415 let mut doc = Document::new(); 1416 let root = doc.root(); 1417 let html = doc.create_element("html"); 1418 let body = doc.create_element("body"); 1419 doc.append_child(root, html); 1420 doc.append_child(html, body); 1421 1422 let tags = ["h1", "h2", "h3"]; 1423 for tag in &tags { 1424 let h = doc.create_element(tag); 1425 let t = doc.create_text(tag); 1426 doc.append_child(body, h); 1427 doc.append_child(h, t); 1428 } 1429 1430 let tree = layout_doc(&doc); 1431 let body_box = &tree.root.children[0]; 1432 1433 let h1 = &body_box.children[0]; 1434 let h2 = &body_box.children[1]; 1435 let h3 = &body_box.children[2]; 1436 assert!(h1.font_size > h2.font_size); 1437 assert!(h2.font_size > h3.font_size); 1438 1439 assert!(h2.rect.y > h1.rect.y); 1440 assert!(h3.rect.y > h2.rect.y); 1441 } 1442 1443 #[test] 1444 fn layout_tree_iteration() { 1445 let mut doc = Document::new(); 1446 let root = doc.root(); 1447 let html = doc.create_element("html"); 1448 let body = doc.create_element("body"); 1449 let p = doc.create_element("p"); 1450 let text = doc.create_text("Test"); 1451 doc.append_child(root, html); 1452 doc.append_child(html, body); 1453 doc.append_child(body, p); 1454 doc.append_child(p, text); 1455 1456 let tree = layout_doc(&doc); 1457 let count = tree.iter().count(); 1458 assert!(count >= 3, "should have at least html, body, p boxes"); 1459 } 1460 1461 #[test] 1462 fn css_style_affects_layout() { 1463 let html_str = r#"<!DOCTYPE html> 1464<html> 1465<head> 1466<style> 1467p { margin-top: 50px; margin-bottom: 50px; } 1468</style> 1469</head> 1470<body> 1471<p>First</p> 1472<p>Second</p> 1473</body> 1474</html>"#; 1475 let doc = we_html::parse_html(html_str); 1476 let font = test_font(); 1477 let sheets = extract_stylesheets(&doc); 1478 let styled = resolve_styles(&doc, &sheets).unwrap(); 1479 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1480 1481 let body_box = &tree.root.children[0]; 1482 let first = &body_box.children[0]; 1483 let second = &body_box.children[1]; 1484 1485 assert_eq!(first.margin.top, 50.0); 1486 assert_eq!(first.margin.bottom, 50.0); 1487 1488 // Adjacent sibling margins collapse: gap = max(50, 50) = 50, not 100. 1489 let gap = second.rect.y - (first.rect.y + first.rect.height); 1490 assert!( 1491 (gap - 50.0).abs() < 1.0, 1492 "collapsed margin gap should be ~50px, got {gap}" 1493 ); 1494 } 1495 1496 #[test] 1497 fn inline_style_affects_layout() { 1498 let html_str = r#"<!DOCTYPE html> 1499<html> 1500<body> 1501<div style="padding-top: 20px; padding-bottom: 20px;"> 1502<p>Content</p> 1503</div> 1504</body> 1505</html>"#; 1506 let doc = we_html::parse_html(html_str); 1507 let font = test_font(); 1508 let sheets = extract_stylesheets(&doc); 1509 let styled = resolve_styles(&doc, &sheets).unwrap(); 1510 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1511 1512 let body_box = &tree.root.children[0]; 1513 let div_box = &body_box.children[0]; 1514 1515 assert_eq!(div_box.padding.top, 20.0); 1516 assert_eq!(div_box.padding.bottom, 20.0); 1517 } 1518 1519 #[test] 1520 fn css_color_propagates_to_layout() { 1521 let html_str = r#"<!DOCTYPE html> 1522<html> 1523<head><style>p { color: red; background-color: blue; }</style></head> 1524<body><p>Colored</p></body> 1525</html>"#; 1526 let doc = we_html::parse_html(html_str); 1527 let font = test_font(); 1528 let sheets = extract_stylesheets(&doc); 1529 let styled = resolve_styles(&doc, &sheets).unwrap(); 1530 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1531 1532 let body_box = &tree.root.children[0]; 1533 let p_box = &body_box.children[0]; 1534 1535 assert_eq!(p_box.color, Color::rgb(255, 0, 0)); 1536 assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); 1537 } 1538 1539 // --- New inline layout tests --- 1540 1541 #[test] 1542 fn inline_elements_have_per_fragment_styling() { 1543 let html_str = r#"<!DOCTYPE html> 1544<html> 1545<head><style>em { color: red; }</style></head> 1546<body><p>Hello <em>world</em></p></body> 1547</html>"#; 1548 let doc = we_html::parse_html(html_str); 1549 let font = test_font(); 1550 let sheets = extract_stylesheets(&doc); 1551 let styled = resolve_styles(&doc, &sheets).unwrap(); 1552 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1553 1554 let body_box = &tree.root.children[0]; 1555 let p_box = &body_box.children[0]; 1556 1557 let colors: Vec<Color> = p_box.lines.iter().map(|l| l.color).collect(); 1558 assert!( 1559 colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), 1560 "should have black text" 1561 ); 1562 assert!( 1563 colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), 1564 "should have red text from <em>" 1565 ); 1566 } 1567 1568 #[test] 1569 fn br_element_forces_line_break() { 1570 let html_str = r#"<!DOCTYPE html> 1571<html> 1572<body><p>Line one<br>Line two</p></body> 1573</html>"#; 1574 let doc = we_html::parse_html(html_str); 1575 let font = test_font(); 1576 let sheets = extract_stylesheets(&doc); 1577 let styled = resolve_styles(&doc, &sheets).unwrap(); 1578 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1579 1580 let body_box = &tree.root.children[0]; 1581 let p_box = &body_box.children[0]; 1582 1583 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1584 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1585 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1586 1587 assert!( 1588 ys.len() >= 2, 1589 "<br> should produce 2 visual lines, got {}", 1590 ys.len() 1591 ); 1592 } 1593 1594 #[test] 1595 fn text_align_center() { 1596 let html_str = r#"<!DOCTYPE html> 1597<html> 1598<head><style>p { text-align: center; }</style></head> 1599<body><p>Hi</p></body> 1600</html>"#; 1601 let doc = we_html::parse_html(html_str); 1602 let font = test_font(); 1603 let sheets = extract_stylesheets(&doc); 1604 let styled = resolve_styles(&doc, &sheets).unwrap(); 1605 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1606 1607 let body_box = &tree.root.children[0]; 1608 let p_box = &body_box.children[0]; 1609 1610 assert!(!p_box.lines.is_empty()); 1611 let first = &p_box.lines[0]; 1612 // Center-aligned: text should be noticeably offset from content x. 1613 assert!( 1614 first.x > p_box.rect.x + 10.0, 1615 "center-aligned text x ({}) should be offset from content x ({})", 1616 first.x, 1617 p_box.rect.x 1618 ); 1619 } 1620 1621 #[test] 1622 fn text_align_right() { 1623 let html_str = r#"<!DOCTYPE html> 1624<html> 1625<head><style>p { text-align: right; }</style></head> 1626<body><p>Hi</p></body> 1627</html>"#; 1628 let doc = we_html::parse_html(html_str); 1629 let font = test_font(); 1630 let sheets = extract_stylesheets(&doc); 1631 let styled = resolve_styles(&doc, &sheets).unwrap(); 1632 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1633 1634 let body_box = &tree.root.children[0]; 1635 let p_box = &body_box.children[0]; 1636 1637 assert!(!p_box.lines.is_empty()); 1638 let first = &p_box.lines[0]; 1639 let right_edge = p_box.rect.x + p_box.rect.width; 1640 assert!( 1641 (first.x + first.width - right_edge).abs() < 1.0, 1642 "right-aligned text end ({}) should be near right edge ({})", 1643 first.x + first.width, 1644 right_edge 1645 ); 1646 } 1647 1648 #[test] 1649 fn inline_padding_offsets_text() { 1650 let html_str = r#"<!DOCTYPE html> 1651<html> 1652<head><style>span { padding-left: 20px; padding-right: 20px; }</style></head> 1653<body><p>A<span>B</span>C</p></body> 1654</html>"#; 1655 let doc = we_html::parse_html(html_str); 1656 let font = test_font(); 1657 let sheets = extract_stylesheets(&doc); 1658 let styled = resolve_styles(&doc, &sheets).unwrap(); 1659 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1660 1661 let body_box = &tree.root.children[0]; 1662 let p_box = &body_box.children[0]; 1663 1664 // Should have at least 3 fragments: A, B, C 1665 assert!( 1666 p_box.lines.len() >= 3, 1667 "should have fragments for A, B, C, got {}", 1668 p_box.lines.len() 1669 ); 1670 1671 // B should be offset by the span's padding. 1672 let a_frag = &p_box.lines[0]; 1673 let b_frag = &p_box.lines[1]; 1674 let gap = b_frag.x - (a_frag.x + a_frag.width); 1675 // Gap should include the 20px padding-left from the span. 1676 assert!( 1677 gap >= 19.0, 1678 "gap between A and B ({gap}) should include span padding-left (20px)" 1679 ); 1680 } 1681 1682 #[test] 1683 fn text_fragments_have_correct_font_size() { 1684 let html_str = r#"<!DOCTYPE html> 1685<html> 1686<body><h1>Big</h1><p>Small</p></body> 1687</html>"#; 1688 let doc = we_html::parse_html(html_str); 1689 let font = test_font(); 1690 let sheets = extract_stylesheets(&doc); 1691 let styled = resolve_styles(&doc, &sheets).unwrap(); 1692 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1693 1694 let body_box = &tree.root.children[0]; 1695 let h1_box = &body_box.children[0]; 1696 let p_box = &body_box.children[1]; 1697 1698 assert!(!h1_box.lines.is_empty()); 1699 assert!(!p_box.lines.is_empty()); 1700 assert_eq!(h1_box.lines[0].font_size, 32.0); 1701 assert_eq!(p_box.lines[0].font_size, 16.0); 1702 } 1703 1704 #[test] 1705 fn line_height_from_computed_style() { 1706 let html_str = r#"<!DOCTYPE html> 1707<html> 1708<head><style>p { line-height: 30px; }</style></head> 1709<body><p>Line one Line two Line three</p></body> 1710</html>"#; 1711 let doc = we_html::parse_html(html_str); 1712 let font = test_font(); 1713 let sheets = extract_stylesheets(&doc); 1714 let styled = resolve_styles(&doc, &sheets).unwrap(); 1715 // Narrow viewport to force wrapping. 1716 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 1717 1718 let body_box = &tree.root.children[0]; 1719 let p_box = &body_box.children[0]; 1720 1721 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1722 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1723 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1724 1725 if ys.len() >= 2 { 1726 let gap = ys[1] - ys[0]; 1727 assert!( 1728 (gap - 30.0).abs() < 1.0, 1729 "line spacing ({gap}) should be ~30px from line-height" 1730 ); 1731 } 1732 } 1733 1734 // --- Relative positioning tests --- 1735 1736 #[test] 1737 fn relative_position_top_left() { 1738 let html_str = r#"<!DOCTYPE html> 1739<html> 1740<body> 1741<div style="position: relative; top: 10px; left: 20px;">Content</div> 1742</body> 1743</html>"#; 1744 let doc = we_html::parse_html(html_str); 1745 let font = test_font(); 1746 let sheets = extract_stylesheets(&doc); 1747 let styled = resolve_styles(&doc, &sheets).unwrap(); 1748 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1749 1750 let body_box = &tree.root.children[0]; 1751 let div_box = &body_box.children[0]; 1752 1753 assert_eq!(div_box.position, Position::Relative); 1754 assert_eq!(div_box.relative_offset, (20.0, 10.0)); 1755 1756 // The div should be shifted from where it would be in normal flow. 1757 // Normal flow position: body.rect.x + margin, body.rect.y + margin. 1758 // With relative offset: shifted by (20, 10). 1759 // Body has 8px margin by default, so content starts at x=8, y=8. 1760 assert!( 1761 (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, 1762 "div x ({}) should be 28.0 (8 + 20)", 1763 div_box.rect.x 1764 ); 1765 assert!( 1766 (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, 1767 "div y ({}) should be 18.0 (8 + 10)", 1768 div_box.rect.y 1769 ); 1770 } 1771 1772 #[test] 1773 fn relative_position_does_not_affect_siblings() { 1774 let html_str = r#"<!DOCTYPE html> 1775<html> 1776<head><style> 1777p { margin: 0; } 1778</style></head> 1779<body> 1780<p id="first" style="position: relative; top: 50px;">First</p> 1781<p id="second">Second</p> 1782</body> 1783</html>"#; 1784 let doc = we_html::parse_html(html_str); 1785 let font = test_font(); 1786 let sheets = extract_stylesheets(&doc); 1787 let styled = resolve_styles(&doc, &sheets).unwrap(); 1788 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1789 1790 let body_box = &tree.root.children[0]; 1791 let first = &body_box.children[0]; 1792 let second = &body_box.children[1]; 1793 1794 // The first paragraph is shifted down by 50px visually. 1795 assert_eq!(first.relative_offset, (0.0, 50.0)); 1796 1797 // But the second paragraph should be at its normal-flow position, 1798 // as if the first paragraph were NOT shifted. The second paragraph 1799 // should come right after the first's normal-flow height. 1800 // Body content starts at y=8 (default body margin). First p has 0 margin. 1801 // Second p should start right after first p's height (without offset). 1802 let first_normal_y = 8.0; // body margin 1803 let first_height = first.rect.height; 1804 let expected_second_y = first_normal_y + first_height; 1805 assert!( 1806 (second.rect.y - expected_second_y).abs() < 1.0, 1807 "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", 1808 second.rect.y 1809 ); 1810 } 1811 1812 #[test] 1813 fn relative_position_conflicting_offsets() { 1814 // When both top and bottom are specified, top wins. 1815 // When both left and right are specified, left wins. 1816 let html_str = r#"<!DOCTYPE html> 1817<html> 1818<body> 1819<div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div> 1820</body> 1821</html>"#; 1822 let doc = we_html::parse_html(html_str); 1823 let font = test_font(); 1824 let sheets = extract_stylesheets(&doc); 1825 let styled = resolve_styles(&doc, &sheets).unwrap(); 1826 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1827 1828 let body_box = &tree.root.children[0]; 1829 let div_box = &body_box.children[0]; 1830 1831 // top wins over bottom: dy = 10 (not -20) 1832 // left wins over right: dx = 30 (not -40) 1833 assert_eq!(div_box.relative_offset, (30.0, 10.0)); 1834 } 1835 1836 #[test] 1837 fn relative_position_auto_offsets() { 1838 // auto offsets should resolve to 0 (no movement). 1839 let html_str = r#"<!DOCTYPE html> 1840<html> 1841<body> 1842<div style="position: relative;">Content</div> 1843</body> 1844</html>"#; 1845 let doc = we_html::parse_html(html_str); 1846 let font = test_font(); 1847 let sheets = extract_stylesheets(&doc); 1848 let styled = resolve_styles(&doc, &sheets).unwrap(); 1849 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1850 1851 let body_box = &tree.root.children[0]; 1852 let div_box = &body_box.children[0]; 1853 1854 assert_eq!(div_box.position, Position::Relative); 1855 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1856 } 1857 1858 #[test] 1859 fn relative_position_bottom_right() { 1860 // bottom: 15px should shift up by 15px (negative direction). 1861 // right: 25px should shift left by 25px (negative direction). 1862 let html_str = r#"<!DOCTYPE html> 1863<html> 1864<body> 1865<div style="position: relative; bottom: 15px; right: 25px;">Content</div> 1866</body> 1867</html>"#; 1868 let doc = we_html::parse_html(html_str); 1869 let font = test_font(); 1870 let sheets = extract_stylesheets(&doc); 1871 let styled = resolve_styles(&doc, &sheets).unwrap(); 1872 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1873 1874 let body_box = &tree.root.children[0]; 1875 let div_box = &body_box.children[0]; 1876 1877 assert_eq!(div_box.relative_offset, (-25.0, -15.0)); 1878 } 1879 1880 #[test] 1881 fn relative_position_shifts_text_lines() { 1882 let html_str = r#"<!DOCTYPE html> 1883<html> 1884<head><style>p { margin: 0; }</style></head> 1885<body> 1886<p style="position: relative; top: 30px; left: 40px;">Hello</p> 1887</body> 1888</html>"#; 1889 let doc = we_html::parse_html(html_str); 1890 let font = test_font(); 1891 let sheets = extract_stylesheets(&doc); 1892 let styled = resolve_styles(&doc, &sheets).unwrap(); 1893 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1894 1895 let body_box = &tree.root.children[0]; 1896 let p_box = &body_box.children[0]; 1897 1898 assert!(!p_box.lines.is_empty(), "p should have text lines"); 1899 let first_line = &p_box.lines[0]; 1900 1901 // Text should be shifted by the relative offset. 1902 // Body content starts at x=8, y=8. With offset: x=48, y=38. 1903 assert!( 1904 first_line.x >= 8.0 + 40.0 - 1.0, 1905 "text x ({}) should be shifted by left offset", 1906 first_line.x 1907 ); 1908 assert!( 1909 first_line.y >= 8.0 + 30.0 - 1.0, 1910 "text y ({}) should be shifted by top offset", 1911 first_line.y 1912 ); 1913 } 1914 1915 #[test] 1916 fn static_position_has_no_offset() { 1917 let html_str = r#"<!DOCTYPE html> 1918<html> 1919<body> 1920<div>Normal flow</div> 1921</body> 1922</html>"#; 1923 let doc = we_html::parse_html(html_str); 1924 let font = test_font(); 1925 let sheets = extract_stylesheets(&doc); 1926 let styled = resolve_styles(&doc, &sheets).unwrap(); 1927 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1928 1929 let body_box = &tree.root.children[0]; 1930 let div_box = &body_box.children[0]; 1931 1932 assert_eq!(div_box.position, Position::Static); 1933 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1934 } 1935 1936 // --- Margin collapsing tests --- 1937 1938 #[test] 1939 fn adjacent_sibling_margins_collapse() { 1940 // Two <p> elements each with margin 16px: gap should be 16px (max), not 32px (sum). 1941 let html_str = r#"<!DOCTYPE html> 1942<html> 1943<head><style> 1944body { margin: 0; border-top: 1px solid black; } 1945p { margin-top: 16px; margin-bottom: 16px; } 1946</style></head> 1947<body> 1948<p>First</p> 1949<p>Second</p> 1950</body> 1951</html>"#; 1952 let doc = we_html::parse_html(html_str); 1953 let font = test_font(); 1954 let sheets = extract_stylesheets(&doc); 1955 let styled = resolve_styles(&doc, &sheets).unwrap(); 1956 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1957 1958 let body_box = &tree.root.children[0]; 1959 let first = &body_box.children[0]; 1960 let second = &body_box.children[1]; 1961 1962 // Gap between first's bottom border-box and second's top border-box 1963 // should be the collapsed margin: max(16, 16) = 16. 1964 let first_bottom = 1965 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 1966 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 1967 assert!( 1968 (gap - 16.0).abs() < 1.0, 1969 "collapsed sibling margin should be ~16px, got {gap}" 1970 ); 1971 } 1972 1973 #[test] 1974 fn sibling_margins_collapse_unequal() { 1975 // p1 bottom-margin 20, p2 top-margin 30: gap should be 30 (max). 1976 let html_str = r#"<!DOCTYPE html> 1977<html> 1978<head><style> 1979body { margin: 0; border-top: 1px solid black; } 1980.first { margin-top: 0; margin-bottom: 20px; } 1981.second { margin-top: 30px; margin-bottom: 0; } 1982</style></head> 1983<body> 1984<p class="first">First</p> 1985<p class="second">Second</p> 1986</body> 1987</html>"#; 1988 let doc = we_html::parse_html(html_str); 1989 let font = test_font(); 1990 let sheets = extract_stylesheets(&doc); 1991 let styled = resolve_styles(&doc, &sheets).unwrap(); 1992 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1993 1994 let body_box = &tree.root.children[0]; 1995 let first = &body_box.children[0]; 1996 let second = &body_box.children[1]; 1997 1998 let first_bottom = 1999 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2000 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2001 assert!( 2002 (gap - 30.0).abs() < 1.0, 2003 "collapsed margin should be max(20, 30) = 30, got {gap}" 2004 ); 2005 } 2006 2007 #[test] 2008 fn parent_first_child_margin_collapsing() { 2009 // Parent with no padding/border: first child's top margin collapses. 2010 let html_str = r#"<!DOCTYPE html> 2011<html> 2012<head><style> 2013body { margin: 0; border-top: 1px solid black; } 2014.parent { margin-top: 10px; } 2015.child { margin-top: 20px; } 2016</style></head> 2017<body> 2018<div class="parent"><p class="child">Child</p></div> 2019</body> 2020</html>"#; 2021 let doc = we_html::parse_html(html_str); 2022 let font = test_font(); 2023 let sheets = extract_stylesheets(&doc); 2024 let styled = resolve_styles(&doc, &sheets).unwrap(); 2025 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2026 2027 let body_box = &tree.root.children[0]; 2028 let parent_box = &body_box.children[0]; 2029 2030 // Parent margin collapses with child's: max(10, 20) = 20. 2031 assert_eq!(parent_box.margin.top, 20.0); 2032 } 2033 2034 #[test] 2035 fn negative_margin_collapsing() { 2036 // One positive (20) and one negative (-10): collapsed = 20 + (-10) = 10. 2037 let html_str = r#"<!DOCTYPE html> 2038<html> 2039<head><style> 2040body { margin: 0; border-top: 1px solid black; } 2041.first { margin-top: 0; margin-bottom: 20px; } 2042.second { margin-top: -10px; margin-bottom: 0; } 2043</style></head> 2044<body> 2045<p class="first">First</p> 2046<p class="second">Second</p> 2047</body> 2048</html>"#; 2049 let doc = we_html::parse_html(html_str); 2050 let font = test_font(); 2051 let sheets = extract_stylesheets(&doc); 2052 let styled = resolve_styles(&doc, &sheets).unwrap(); 2053 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2054 2055 let body_box = &tree.root.children[0]; 2056 let first = &body_box.children[0]; 2057 let second = &body_box.children[1]; 2058 2059 let first_bottom = 2060 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2061 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2062 // 20 + (-10) = 10 2063 assert!( 2064 (gap - 10.0).abs() < 1.0, 2065 "positive + negative margin collapse should be 10, got {gap}" 2066 ); 2067 } 2068 2069 #[test] 2070 fn both_negative_margins_collapse() { 2071 // Both negative: use the more negative value. 2072 let html_str = r#"<!DOCTYPE html> 2073<html> 2074<head><style> 2075body { margin: 0; border-top: 1px solid black; } 2076.first { margin-top: 0; margin-bottom: -10px; } 2077.second { margin-top: -20px; margin-bottom: 0; } 2078</style></head> 2079<body> 2080<p class="first">First</p> 2081<p class="second">Second</p> 2082</body> 2083</html>"#; 2084 let doc = we_html::parse_html(html_str); 2085 let font = test_font(); 2086 let sheets = extract_stylesheets(&doc); 2087 let styled = resolve_styles(&doc, &sheets).unwrap(); 2088 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2089 2090 let body_box = &tree.root.children[0]; 2091 let first = &body_box.children[0]; 2092 let second = &body_box.children[1]; 2093 2094 let first_bottom = 2095 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2096 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2097 // Both negative: min(-10, -20) = -20 2098 assert!( 2099 (gap - (-20.0)).abs() < 1.0, 2100 "both-negative margin collapse should be -20, got {gap}" 2101 ); 2102 } 2103 2104 #[test] 2105 fn border_blocks_margin_collapsing() { 2106 // When border separates margins, they don't collapse. 2107 let html_str = r#"<!DOCTYPE html> 2108<html> 2109<head><style> 2110body { margin: 0; border-top: 1px solid black; } 2111.first { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid black; } 2112.second { margin-top: 20px; border-top: 1px solid black; } 2113</style></head> 2114<body> 2115<p class="first">First</p> 2116<p class="second">Second</p> 2117</body> 2118</html>"#; 2119 let doc = we_html::parse_html(html_str); 2120 let font = test_font(); 2121 let sheets = extract_stylesheets(&doc); 2122 let styled = resolve_styles(&doc, &sheets).unwrap(); 2123 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2124 2125 let body_box = &tree.root.children[0]; 2126 let first = &body_box.children[0]; 2127 let second = &body_box.children[1]; 2128 2129 // Borders are on the elements themselves, but the MARGINS are still 2130 // between the border boxes — sibling margins still collapse regardless 2131 // of borders on the elements. The margin gap = max(20, 20) = 20. 2132 let first_bottom = 2133 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2134 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2135 assert!( 2136 (gap - 20.0).abs() < 1.0, 2137 "sibling margins collapse even with borders on elements, gap should be 20, got {gap}" 2138 ); 2139 } 2140 2141 #[test] 2142 fn padding_blocks_parent_child_collapsing() { 2143 // Parent with padding-top prevents margin collapsing with first child. 2144 let html_str = r#"<!DOCTYPE html> 2145<html> 2146<head><style> 2147body { margin: 0; border-top: 1px solid black; } 2148.parent { margin-top: 10px; padding-top: 5px; } 2149.child { margin-top: 20px; } 2150</style></head> 2151<body> 2152<div class="parent"><p class="child">Child</p></div> 2153</body> 2154</html>"#; 2155 let doc = we_html::parse_html(html_str); 2156 let font = test_font(); 2157 let sheets = extract_stylesheets(&doc); 2158 let styled = resolve_styles(&doc, &sheets).unwrap(); 2159 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2160 2161 let body_box = &tree.root.children[0]; 2162 let parent_box = &body_box.children[0]; 2163 2164 // Parent has padding-top, so no collapsing: margin stays at 10. 2165 assert_eq!(parent_box.margin.top, 10.0); 2166 } 2167 2168 #[test] 2169 fn empty_block_margins_collapse() { 2170 // An empty div's top and bottom margins collapse with adjacent margins. 2171 let html_str = r#"<!DOCTYPE html> 2172<html> 2173<head><style> 2174body { margin: 0; border-top: 1px solid black; } 2175.spacer { margin-top: 10px; margin-bottom: 10px; } 2176p { margin-top: 5px; margin-bottom: 5px; } 2177</style></head> 2178<body> 2179<p>Before</p> 2180<div class="spacer"></div> 2181<p>After</p> 2182</body> 2183</html>"#; 2184 let doc = we_html::parse_html(html_str); 2185 let font = test_font(); 2186 let sheets = extract_stylesheets(&doc); 2187 let styled = resolve_styles(&doc, &sheets).unwrap(); 2188 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2189 2190 let body_box = &tree.root.children[0]; 2191 let before = &body_box.children[0]; 2192 let after = &body_box.children[2]; // [0]=p, [1]=empty div, [2]=p 2193 2194 // Empty div's margins (10+10) self-collapse to max(10,10)=10. 2195 // Then collapse with before's bottom (5) and after's top (5): 2196 // collapse(5, collapse(10, 10)) = collapse(5, 10) = 10 2197 // Then collapse(10, 5) = 10. 2198 // So total gap between before and after = 10. 2199 let before_bottom = 2200 before.rect.y + before.rect.height + before.padding.bottom + before.border.bottom; 2201 let gap = after.rect.y - after.border.top - after.padding.top - before_bottom; 2202 assert!( 2203 (gap - 10.0).abs() < 1.0, 2204 "empty block margin collapse gap should be ~10px, got {gap}" 2205 ); 2206 } 2207 2208 #[test] 2209 fn collapse_margins_unit() { 2210 // Unit tests for the collapse_margins helper. 2211 assert_eq!(collapse_margins(10.0, 20.0), 20.0); 2212 assert_eq!(collapse_margins(20.0, 10.0), 20.0); 2213 assert_eq!(collapse_margins(0.0, 15.0), 15.0); 2214 assert_eq!(collapse_margins(-5.0, -10.0), -10.0); 2215 assert_eq!(collapse_margins(20.0, -5.0), 15.0); 2216 assert_eq!(collapse_margins(-5.0, 20.0), 15.0); 2217 assert_eq!(collapse_margins(0.0, 0.0), 0.0); 2218 } 2219 2220 // --- Box-sizing tests --- 2221 2222 #[test] 2223 fn content_box_default_width_applies_to_content() { 2224 // Default box-sizing (content-box): width = content width only. 2225 let html_str = r#"<!DOCTYPE html> 2226<html> 2227<head><style> 2228body { margin: 0; } 2229div { width: 200px; padding: 10px; border: 5px solid black; } 2230</style></head> 2231<body> 2232<div>Content</div> 2233</body> 2234</html>"#; 2235 let doc = we_html::parse_html(html_str); 2236 let font = test_font(); 2237 let sheets = extract_stylesheets(&doc); 2238 let styled = resolve_styles(&doc, &sheets).unwrap(); 2239 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2240 2241 let body_box = &tree.root.children[0]; 2242 let div_box = &body_box.children[0]; 2243 2244 // content-box: rect.width = 200 (the specified width IS the content) 2245 assert_eq!(div_box.rect.width, 200.0); 2246 assert_eq!(div_box.padding.left, 10.0); 2247 assert_eq!(div_box.padding.right, 10.0); 2248 assert_eq!(div_box.border.left, 5.0); 2249 assert_eq!(div_box.border.right, 5.0); 2250 } 2251 2252 #[test] 2253 fn border_box_width_includes_padding_and_border() { 2254 // box-sizing: border-box: width includes padding and border. 2255 let html_str = r#"<!DOCTYPE html> 2256<html> 2257<head><style> 2258body { margin: 0; } 2259div { box-sizing: border-box; width: 200px; padding: 10px; border: 5px solid black; } 2260</style></head> 2261<body> 2262<div>Content</div> 2263</body> 2264</html>"#; 2265 let doc = we_html::parse_html(html_str); 2266 let font = test_font(); 2267 let sheets = extract_stylesheets(&doc); 2268 let styled = resolve_styles(&doc, &sheets).unwrap(); 2269 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2270 2271 let body_box = &tree.root.children[0]; 2272 let div_box = &body_box.children[0]; 2273 2274 // border-box: content width = 200 - 10*2 (padding) - 5*2 (border) = 170 2275 assert_eq!(div_box.rect.width, 170.0); 2276 assert_eq!(div_box.padding.left, 10.0); 2277 assert_eq!(div_box.padding.right, 10.0); 2278 assert_eq!(div_box.border.left, 5.0); 2279 assert_eq!(div_box.border.right, 5.0); 2280 } 2281 2282 #[test] 2283 fn border_box_padding_exceeds_width_clamps_to_zero() { 2284 // border-box with padding+border > specified width: content clamps to 0. 2285 let html_str = r#"<!DOCTYPE html> 2286<html> 2287<head><style> 2288body { margin: 0; } 2289div { box-sizing: border-box; width: 20px; padding: 15px; border: 5px solid black; } 2290</style></head> 2291<body> 2292<div>X</div> 2293</body> 2294</html>"#; 2295 let doc = we_html::parse_html(html_str); 2296 let font = test_font(); 2297 let sheets = extract_stylesheets(&doc); 2298 let styled = resolve_styles(&doc, &sheets).unwrap(); 2299 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2300 2301 let body_box = &tree.root.children[0]; 2302 let div_box = &body_box.children[0]; 2303 2304 // border-box: content = 20 - 15*2 - 5*2 = 20 - 40 = -20 → clamped to 0 2305 assert_eq!(div_box.rect.width, 0.0); 2306 } 2307 2308 #[test] 2309 fn box_sizing_is_not_inherited() { 2310 // box-sizing is not inherited: child should use default content-box. 2311 let html_str = r#"<!DOCTYPE html> 2312<html> 2313<head><style> 2314body { margin: 0; } 2315.parent { box-sizing: border-box; width: 300px; padding: 10px; border: 5px solid black; } 2316.child { width: 100px; padding: 10px; border: 5px solid black; } 2317</style></head> 2318<body> 2319<div class="parent"><div class="child">Inner</div></div> 2320</body> 2321</html>"#; 2322 let doc = we_html::parse_html(html_str); 2323 let font = test_font(); 2324 let sheets = extract_stylesheets(&doc); 2325 let styled = resolve_styles(&doc, &sheets).unwrap(); 2326 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2327 2328 let body_box = &tree.root.children[0]; 2329 let parent_box = &body_box.children[0]; 2330 let child_box = &parent_box.children[0]; 2331 2332 // Parent: border-box → content = 300 - 20 - 10 = 270 2333 assert_eq!(parent_box.rect.width, 270.0); 2334 // Child: default content-box → content = 100 (not reduced by padding/border) 2335 assert_eq!(child_box.rect.width, 100.0); 2336 } 2337 2338 #[test] 2339 fn border_box_height() { 2340 // box-sizing: border-box also applies to height. 2341 let html_str = r#"<!DOCTYPE html> 2342<html> 2343<head><style> 2344body { margin: 0; } 2345div { box-sizing: border-box; height: 100px; padding: 10px; border: 5px solid black; } 2346</style></head> 2347<body> 2348<div>Content</div> 2349</body> 2350</html>"#; 2351 let doc = we_html::parse_html(html_str); 2352 let font = test_font(); 2353 let sheets = extract_stylesheets(&doc); 2354 let styled = resolve_styles(&doc, &sheets).unwrap(); 2355 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2356 2357 let body_box = &tree.root.children[0]; 2358 let div_box = &body_box.children[0]; 2359 2360 // border-box: content height = 100 - 10*2 (padding) - 5*2 (border) = 70 2361 assert_eq!(div_box.rect.height, 70.0); 2362 } 2363 2364 #[test] 2365 fn content_box_explicit_height() { 2366 // content-box: height applies to content only. 2367 let html_str = r#"<!DOCTYPE html> 2368<html> 2369<head><style> 2370body { margin: 0; } 2371div { height: 100px; padding: 10px; border: 5px solid black; } 2372</style></head> 2373<body> 2374<div>Content</div> 2375</body> 2376</html>"#; 2377 let doc = we_html::parse_html(html_str); 2378 let font = test_font(); 2379 let sheets = extract_stylesheets(&doc); 2380 let styled = resolve_styles(&doc, &sheets).unwrap(); 2381 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2382 2383 let body_box = &tree.root.children[0]; 2384 let div_box = &body_box.children[0]; 2385 2386 // content-box: rect.height = 100 (specified height IS content height) 2387 assert_eq!(div_box.rect.height, 100.0); 2388 } 2389}