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