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