web engine - experimental web browser

Implement basic block layout engine

Build layout trees from DOM documents with block-level element stacking,
text word-wrapping, and hardcoded default styles for Phase 3.

- LayoutBox tree with Block, Inline, TextRun, and Anonymous box types
- Block layout: children stack vertically, take full parent width
- Inline layout: collect text from inline children, word-wrap at container width
- Default styles: body margin 8px, p margins 1em, h1-h6 font sizes
- Anonymous block wrapping for mixed block/inline children
- Line height 1.2em, whitespace collapsing
- LayoutTree iterator for depth-first traversal
- 17 unit tests covering all acceptance criteria

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+976 -1
+976 -1
crates/layout/src/lib.rs
··· 1 - //! Box generation, block/inline/flex/grid/table layout. 1 + //! Block layout engine: box generation, block/inline layout, and text wrapping. 2 + //! 3 + //! Builds a layout tree from a DOM document and positions block-level elements 4 + //! vertically with text wrapping. Uses hardcoded default styles (no CSS yet). 5 + 6 + use we_dom::{Document, NodeData, NodeId}; 7 + use we_text::font::Font; 8 + 9 + /// Edge sizes for box model (margin, padding, border). 10 + #[derive(Debug, Clone, Copy, Default, PartialEq)] 11 + pub struct EdgeSizes { 12 + pub top: f32, 13 + pub right: f32, 14 + pub bottom: f32, 15 + pub left: f32, 16 + } 17 + 18 + /// A positioned rectangle with content area dimensions. 19 + #[derive(Debug, Clone, Copy, Default, PartialEq)] 20 + pub struct Rect { 21 + pub x: f32, 22 + pub y: f32, 23 + pub width: f32, 24 + pub height: f32, 25 + } 26 + 27 + /// The type of layout box. 28 + #[derive(Debug)] 29 + pub enum BoxType { 30 + /// Block-level box from an element. 31 + Block(NodeId), 32 + /// Inline-level box from an element. 33 + Inline(NodeId), 34 + /// A run of text from a text node. 35 + TextRun { node: NodeId, text: String }, 36 + /// Anonymous block wrapping inline content within a block container. 37 + Anonymous, 38 + } 39 + 40 + /// A single line of wrapped text. 41 + #[derive(Debug, Clone, PartialEq)] 42 + pub struct TextLine { 43 + pub text: String, 44 + pub x: f32, 45 + pub y: f32, 46 + pub width: f32, 47 + } 48 + 49 + /// A box in the layout tree with dimensions and child boxes. 50 + #[derive(Debug)] 51 + pub struct LayoutBox { 52 + pub box_type: BoxType, 53 + pub rect: Rect, 54 + pub margin: EdgeSizes, 55 + pub padding: EdgeSizes, 56 + pub border: EdgeSizes, 57 + pub children: Vec<LayoutBox>, 58 + pub font_size: f32, 59 + /// Wrapped text lines (populated for boxes with inline content). 60 + pub lines: Vec<TextLine>, 61 + } 62 + 63 + impl LayoutBox { 64 + fn new(box_type: BoxType, font_size: f32) -> Self { 65 + LayoutBox { 66 + box_type, 67 + rect: Rect::default(), 68 + margin: EdgeSizes::default(), 69 + padding: EdgeSizes::default(), 70 + border: EdgeSizes::default(), 71 + children: Vec::new(), 72 + font_size, 73 + lines: Vec::new(), 74 + } 75 + } 76 + 77 + /// Total height including margin, border, and padding. 78 + pub fn margin_box_height(&self) -> f32 { 79 + self.margin.top 80 + + self.border.top 81 + + self.padding.top 82 + + self.rect.height 83 + + self.padding.bottom 84 + + self.border.bottom 85 + + self.margin.bottom 86 + } 87 + 88 + /// Iterate over all boxes in depth-first pre-order. 89 + pub fn iter(&self) -> LayoutBoxIter<'_> { 90 + LayoutBoxIter { stack: vec![self] } 91 + } 92 + } 93 + 94 + /// Depth-first pre-order iterator over layout boxes. 95 + pub struct LayoutBoxIter<'a> { 96 + stack: Vec<&'a LayoutBox>, 97 + } 98 + 99 + impl<'a> Iterator for LayoutBoxIter<'a> { 100 + type Item = &'a LayoutBox; 101 + 102 + fn next(&mut self) -> Option<&'a LayoutBox> { 103 + let node = self.stack.pop()?; 104 + // Push children in reverse so leftmost child is visited first. 105 + for child in node.children.iter().rev() { 106 + self.stack.push(child); 107 + } 108 + Some(node) 109 + } 110 + } 111 + 112 + /// The result of laying out a document. 113 + #[derive(Debug)] 114 + pub struct LayoutTree { 115 + pub root: LayoutBox, 116 + pub width: f32, 117 + pub height: f32, 118 + } 119 + 120 + impl LayoutTree { 121 + /// Iterate over all layout boxes in depth-first pre-order. 122 + pub fn iter(&self) -> LayoutBoxIter<'_> { 123 + self.root.iter() 124 + } 125 + } 126 + 127 + // --------------------------------------------------------------------------- 128 + // Display type classification 129 + // --------------------------------------------------------------------------- 130 + 131 + #[derive(Debug, Clone, Copy, PartialEq)] 132 + enum DisplayType { 133 + Block, 134 + Inline, 135 + None, 136 + } 137 + 138 + fn display_type(tag: &str) -> DisplayType { 139 + match tag { 140 + "html" | "body" | "div" | "p" | "pre" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" 141 + | "ol" | "li" | "blockquote" | "section" | "article" | "nav" | "header" | "footer" 142 + | "main" | "hr" => DisplayType::Block, 143 + 144 + "span" | "a" | "em" | "strong" | "b" | "i" | "u" | "code" | "small" | "sub" | "sup" 145 + | "br" => DisplayType::Inline, 146 + 147 + "head" | "title" | "script" | "style" | "link" | "meta" => DisplayType::None, 148 + 149 + _ => DisplayType::Block, 150 + } 151 + } 152 + 153 + // --------------------------------------------------------------------------- 154 + // Default styles (hardcoded for Phase 3) 155 + // --------------------------------------------------------------------------- 156 + 157 + fn default_font_size(tag: &str, parent_size: f32) -> f32 { 158 + match tag { 159 + "h1" => parent_size * 2.0, 160 + "h2" => parent_size * 1.5, 161 + "h3" => parent_size * 1.17, 162 + "h4" => parent_size, 163 + "h5" => parent_size * 0.83, 164 + "h6" => parent_size * 0.67, 165 + _ => parent_size, 166 + } 167 + } 168 + 169 + fn default_margin(tag: &str, font_size: f32) -> EdgeSizes { 170 + match tag { 171 + "body" => EdgeSizes { 172 + top: 8.0, 173 + right: 8.0, 174 + bottom: 8.0, 175 + left: 8.0, 176 + }, 177 + "p" => EdgeSizes { 178 + top: font_size, 179 + bottom: font_size, 180 + ..EdgeSizes::default() 181 + }, 182 + "h1" => EdgeSizes { 183 + top: font_size * 0.67, 184 + bottom: font_size * 0.67, 185 + ..EdgeSizes::default() 186 + }, 187 + "h2" => EdgeSizes { 188 + top: font_size * 0.83, 189 + bottom: font_size * 0.83, 190 + ..EdgeSizes::default() 191 + }, 192 + "h3" | "h4" => EdgeSizes { 193 + top: font_size, 194 + bottom: font_size, 195 + ..EdgeSizes::default() 196 + }, 197 + "h5" | "h6" => EdgeSizes { 198 + top: font_size * 1.67, 199 + bottom: font_size * 1.67, 200 + ..EdgeSizes::default() 201 + }, 202 + _ => EdgeSizes::default(), 203 + } 204 + } 205 + 206 + // --------------------------------------------------------------------------- 207 + // Build layout tree from DOM 208 + // --------------------------------------------------------------------------- 209 + 210 + fn build_box(doc: &Document, node: NodeId, parent_font_size: f32) -> Option<LayoutBox> { 211 + match doc.node_data(node) { 212 + NodeData::Document => { 213 + let mut children = Vec::new(); 214 + for child in doc.children(node) { 215 + if let Some(child_box) = build_box(doc, child, parent_font_size) { 216 + children.push(child_box); 217 + } 218 + } 219 + // Unwrap single root element (typically <html>). 220 + if children.len() == 1 { 221 + children.into_iter().next() 222 + } else if children.is_empty() { 223 + None 224 + } else { 225 + let mut b = LayoutBox::new(BoxType::Anonymous, parent_font_size); 226 + b.children = children; 227 + Some(b) 228 + } 229 + } 230 + NodeData::Element { tag_name, .. } => { 231 + let dt = display_type(tag_name); 232 + if dt == DisplayType::None { 233 + return None; 234 + } 235 + 236 + let font_size = default_font_size(tag_name, parent_font_size); 237 + let margin = default_margin(tag_name, font_size); 238 + 239 + let mut children = Vec::new(); 240 + for child in doc.children(node) { 241 + if let Some(child_box) = build_box(doc, child, font_size) { 242 + children.push(child_box); 243 + } 244 + } 245 + 246 + let box_type = match dt { 247 + DisplayType::Block => BoxType::Block(node), 248 + DisplayType::Inline => BoxType::Inline(node), 249 + DisplayType::None => unreachable!(), 250 + }; 251 + 252 + // For block containers, ensure children are uniformly block or inline. 253 + if dt == DisplayType::Block { 254 + children = normalize_children(children, font_size); 255 + } 256 + 257 + let mut b = LayoutBox::new(box_type, font_size); 258 + b.margin = margin; 259 + b.children = children; 260 + Some(b) 261 + } 262 + NodeData::Text { data } => { 263 + let collapsed = collapse_whitespace(data); 264 + if collapsed.is_empty() { 265 + return None; 266 + } 267 + Some(LayoutBox::new( 268 + BoxType::TextRun { 269 + node, 270 + text: collapsed, 271 + }, 272 + parent_font_size, 273 + )) 274 + } 275 + NodeData::Comment { .. } => None, 276 + } 277 + } 278 + 279 + /// Collapse runs of whitespace to a single space. Preserves non-whitespace content. 280 + fn collapse_whitespace(s: &str) -> String { 281 + let mut result = String::new(); 282 + let mut in_ws = false; 283 + for ch in s.chars() { 284 + if ch.is_whitespace() { 285 + if !in_ws { 286 + result.push(' '); 287 + } 288 + in_ws = true; 289 + } else { 290 + in_ws = false; 291 + result.push(ch); 292 + } 293 + } 294 + result 295 + } 296 + 297 + /// If a block container has a mix of block-level and inline-level children, 298 + /// wrap consecutive inline runs in anonymous block boxes. 299 + fn normalize_children(children: Vec<LayoutBox>, font_size: f32) -> Vec<LayoutBox> { 300 + if children.is_empty() { 301 + return children; 302 + } 303 + 304 + let has_block = children.iter().any(is_block_level); 305 + if !has_block { 306 + // All inline — parent will do inline layout directly. 307 + return children; 308 + } 309 + 310 + let has_inline = children.iter().any(|c| !is_block_level(c)); 311 + if !has_inline { 312 + // All block — no wrapping needed. 313 + return children; 314 + } 315 + 316 + // Mixed: wrap consecutive inline runs in anonymous blocks. 317 + let mut result = Vec::new(); 318 + let mut inline_group: Vec<LayoutBox> = Vec::new(); 319 + 320 + for child in children { 321 + if is_block_level(&child) { 322 + if !inline_group.is_empty() { 323 + let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); 324 + anon.children = std::mem::take(&mut inline_group); 325 + result.push(anon); 326 + } 327 + result.push(child); 328 + } else { 329 + inline_group.push(child); 330 + } 331 + } 332 + 333 + if !inline_group.is_empty() { 334 + let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); 335 + anon.children = inline_group; 336 + result.push(anon); 337 + } 338 + 339 + result 340 + } 341 + 342 + fn is_block_level(b: &LayoutBox) -> bool { 343 + matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) 344 + } 345 + 346 + // --------------------------------------------------------------------------- 347 + // Layout algorithm 348 + // --------------------------------------------------------------------------- 349 + 350 + /// Position and size a layout box within `available_width` at position (`x`, `y`). 351 + /// 352 + /// `x` and `y` mark the top-left corner of the box's margin area. 353 + fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: &Font) { 354 + let content_x = x + b.margin.left + b.border.left + b.padding.left; 355 + let content_y = y + b.margin.top + b.border.top + b.padding.top; 356 + let content_width = (available_width 357 + - b.margin.left 358 + - b.margin.right 359 + - b.border.left 360 + - b.border.right 361 + - b.padding.left 362 + - b.padding.right) 363 + .max(0.0); 364 + 365 + b.rect.x = content_x; 366 + b.rect.y = content_y; 367 + b.rect.width = content_width; 368 + 369 + match &b.box_type { 370 + BoxType::Block(_) | BoxType::Anonymous => { 371 + if has_block_children(b) { 372 + layout_block_children(b, font); 373 + } else { 374 + layout_inline_children(b, font); 375 + } 376 + } 377 + BoxType::TextRun { .. } | BoxType::Inline(_) => { 378 + // Handled by the parent's inline layout. 379 + } 380 + } 381 + } 382 + 383 + fn has_block_children(b: &LayoutBox) -> bool { 384 + b.children.iter().any(is_block_level) 385 + } 386 + 387 + /// Lay out block-level children: stack them vertically. 388 + fn layout_block_children(parent: &mut LayoutBox, font: &Font) { 389 + let content_x = parent.rect.x; 390 + let content_width = parent.rect.width; 391 + let mut cursor_y = parent.rect.y; 392 + 393 + for child in &mut parent.children { 394 + compute_layout(child, content_x, cursor_y, content_width, font); 395 + cursor_y += child.margin_box_height(); 396 + } 397 + 398 + parent.rect.height = cursor_y - parent.rect.y; 399 + } 400 + 401 + /// Lay out inline children: collect text, word-wrap, and compute height. 402 + fn layout_inline_children(parent: &mut LayoutBox, font: &Font) { 403 + let text = collect_inline_text(&parent.children); 404 + if text.is_empty() { 405 + parent.rect.height = 0.0; 406 + return; 407 + } 408 + 409 + let line_height = parent.font_size * 1.2; 410 + let wrapped = wrap_text(&text, parent.rect.width, font, parent.font_size); 411 + 412 + let mut y = parent.rect.y; 413 + let mut positioned = Vec::with_capacity(wrapped.len()); 414 + for line in wrapped { 415 + positioned.push(TextLine { 416 + text: line.text, 417 + x: parent.rect.x, 418 + y, 419 + width: line.width, 420 + }); 421 + y += line_height; 422 + } 423 + 424 + parent.rect.height = positioned.len() as f32 * line_height; 425 + parent.lines = positioned; 426 + } 427 + 428 + /// Recursively collect all text from inline children. 429 + fn collect_inline_text(children: &[LayoutBox]) -> String { 430 + let mut result = String::new(); 431 + collect_text_recursive(children, &mut result); 432 + result 433 + } 434 + 435 + fn collect_text_recursive(children: &[LayoutBox], result: &mut String) { 436 + for child in children { 437 + match &child.box_type { 438 + BoxType::TextRun { text, .. } => { 439 + result.push_str(text); 440 + } 441 + BoxType::Inline(_) => { 442 + collect_text_recursive(&child.children, result); 443 + } 444 + _ => {} 445 + } 446 + } 447 + } 448 + 449 + // --------------------------------------------------------------------------- 450 + // Text measurement and word wrapping 451 + // --------------------------------------------------------------------------- 452 + 453 + /// Measure the total advance width of a text string at the given font size. 454 + fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { 455 + let shaped = font.shape_text(text, font_size); 456 + match shaped.last() { 457 + Some(last) => last.x_offset + last.x_advance, 458 + None => 0.0, 459 + } 460 + } 461 + 462 + /// Word-wrap text to fit within `max_width`. 463 + fn wrap_text(text: &str, max_width: f32, font: &Font, font_size: f32) -> Vec<TextLine> { 464 + let words: Vec<&str> = text.split_whitespace().collect(); 465 + if words.is_empty() { 466 + return Vec::new(); 467 + } 468 + 469 + let space_width = measure_text_width(font, " ", font_size); 470 + let mut lines = Vec::new(); 471 + let mut line_text = String::new(); 472 + let mut line_width: f32 = 0.0; 473 + 474 + for word in &words { 475 + let word_width = measure_text_width(font, word, font_size); 476 + 477 + if line_text.is_empty() { 478 + // First word on line — always accept. 479 + line_text.push_str(word); 480 + line_width = word_width; 481 + } else if line_width + space_width + word_width <= max_width { 482 + line_text.push(' '); 483 + line_text.push_str(word); 484 + line_width += space_width + word_width; 485 + } else { 486 + // Emit current line, start new one. 487 + lines.push(TextLine { 488 + text: line_text, 489 + x: 0.0, 490 + y: 0.0, 491 + width: line_width, 492 + }); 493 + line_text = word.to_string(); 494 + line_width = word_width; 495 + } 496 + } 497 + 498 + if !line_text.is_empty() { 499 + lines.push(TextLine { 500 + text: line_text, 501 + x: 0.0, 502 + y: 0.0, 503 + width: line_width, 504 + }); 505 + } 506 + 507 + lines 508 + } 509 + 510 + // --------------------------------------------------------------------------- 511 + // Public API 512 + // --------------------------------------------------------------------------- 513 + 514 + const BASE_FONT_SIZE: f32 = 16.0; 515 + 516 + /// Build and lay out a DOM document. 517 + /// 518 + /// Returns a `LayoutTree` with positioned boxes ready for rendering. 519 + pub fn layout( 520 + document: &Document, 521 + viewport_width: f32, 522 + _viewport_height: f32, 523 + font: &Font, 524 + ) -> LayoutTree { 525 + let mut root = match build_box(document, document.root(), BASE_FONT_SIZE) { 526 + Some(b) => b, 527 + None => { 528 + return LayoutTree { 529 + root: LayoutBox::new(BoxType::Anonymous, BASE_FONT_SIZE), 530 + width: viewport_width, 531 + height: 0.0, 532 + }; 533 + } 534 + }; 535 + 536 + compute_layout(&mut root, 0.0, 0.0, viewport_width, font); 537 + 538 + let height = root.margin_box_height(); 539 + LayoutTree { 540 + root, 541 + width: viewport_width, 542 + height, 543 + } 544 + } 545 + 546 + #[cfg(test)] 547 + mod tests { 548 + use super::*; 549 + use we_dom::Document; 550 + 551 + // Helper: load a system font for testing. 552 + fn test_font() -> Font { 553 + let paths = [ 554 + "/System/Library/Fonts/Geneva.ttf", 555 + "/System/Library/Fonts/Monaco.ttf", 556 + ]; 557 + for path in &paths { 558 + let p = std::path::Path::new(path); 559 + if p.exists() { 560 + return Font::from_file(p).expect("failed to parse font"); 561 + } 562 + } 563 + panic!("no test font found"); 564 + } 565 + 566 + // Helper: build a simple DOM and lay it out. 567 + fn layout_simple_html(html_element: NodeId, doc: &Document) -> LayoutTree { 568 + let _ = html_element; // doc.root() already wraps it 569 + let font = test_font(); 570 + layout(doc, 800.0, 600.0, &font) 571 + } 572 + 573 + #[test] 574 + fn empty_document() { 575 + let doc = Document::new(); 576 + let font = test_font(); 577 + let tree = layout(&doc, 800.0, 600.0, &font); 578 + // Empty document should produce a minimal layout. 579 + assert_eq!(tree.width, 800.0); 580 + } 581 + 582 + #[test] 583 + fn single_paragraph() { 584 + // Build: <html><body><p>Hello world</p></body></html> 585 + let mut doc = Document::new(); 586 + let root = doc.root(); 587 + let html = doc.create_element("html"); 588 + let body = doc.create_element("body"); 589 + let p = doc.create_element("p"); 590 + let text = doc.create_text("Hello world"); 591 + doc.append_child(root, html); 592 + doc.append_child(html, body); 593 + doc.append_child(body, p); 594 + doc.append_child(p, text); 595 + 596 + let tree = layout_simple_html(html, &doc); 597 + 598 + // Root should be the html element box. 599 + assert!(matches!(tree.root.box_type, BoxType::Block(_))); 600 + 601 + // Find the p box (html > body > p). 602 + let body_box = &tree.root.children[0]; 603 + assert!(matches!(body_box.box_type, BoxType::Block(_))); 604 + 605 + let p_box = &body_box.children[0]; 606 + assert!(matches!(p_box.box_type, BoxType::Block(_))); 607 + 608 + // p should have text lines. 609 + assert!(!p_box.lines.is_empty(), "p should have wrapped text lines"); 610 + assert_eq!(p_box.lines[0].text, "Hello world"); 611 + 612 + // p should have vertical margins (1em = 16px default). 613 + assert_eq!(p_box.margin.top, 16.0); 614 + assert_eq!(p_box.margin.bottom, 16.0); 615 + } 616 + 617 + #[test] 618 + fn blocks_stack_vertically() { 619 + // <html><body><p>First</p><p>Second</p></body></html> 620 + let mut doc = Document::new(); 621 + let root = doc.root(); 622 + let html = doc.create_element("html"); 623 + let body = doc.create_element("body"); 624 + let p1 = doc.create_element("p"); 625 + let t1 = doc.create_text("First"); 626 + let p2 = doc.create_element("p"); 627 + let t2 = doc.create_text("Second"); 628 + doc.append_child(root, html); 629 + doc.append_child(html, body); 630 + doc.append_child(body, p1); 631 + doc.append_child(p1, t1); 632 + doc.append_child(body, p2); 633 + doc.append_child(p2, t2); 634 + 635 + let tree = layout_simple_html(html, &doc); 636 + let body_box = &tree.root.children[0]; 637 + let first = &body_box.children[0]; 638 + let second = &body_box.children[1]; 639 + 640 + // Second paragraph should be below the first. 641 + assert!( 642 + second.rect.y > first.rect.y, 643 + "second p (y={}) should be below first p (y={})", 644 + second.rect.y, 645 + first.rect.y 646 + ); 647 + } 648 + 649 + #[test] 650 + fn heading_larger_than_body() { 651 + // <html><body><h1>Title</h1><p>Text</p></body></html> 652 + let mut doc = Document::new(); 653 + let root = doc.root(); 654 + let html = doc.create_element("html"); 655 + let body = doc.create_element("body"); 656 + let h1 = doc.create_element("h1"); 657 + let h1_text = doc.create_text("Title"); 658 + let p = doc.create_element("p"); 659 + let p_text = doc.create_text("Text"); 660 + doc.append_child(root, html); 661 + doc.append_child(html, body); 662 + doc.append_child(body, h1); 663 + doc.append_child(h1, h1_text); 664 + doc.append_child(body, p); 665 + doc.append_child(p, p_text); 666 + 667 + let tree = layout_simple_html(html, &doc); 668 + let body_box = &tree.root.children[0]; 669 + let h1_box = &body_box.children[0]; 670 + let p_box = &body_box.children[1]; 671 + 672 + // h1 should have a larger font size (2em = 32px). 673 + assert!( 674 + h1_box.font_size > p_box.font_size, 675 + "h1 font_size ({}) should be > p font_size ({})", 676 + h1_box.font_size, 677 + p_box.font_size 678 + ); 679 + assert_eq!(h1_box.font_size, 32.0); 680 + 681 + // h1 should take more vertical space. 682 + assert!( 683 + h1_box.rect.height > p_box.rect.height, 684 + "h1 height ({}) should be > p height ({})", 685 + h1_box.rect.height, 686 + p_box.rect.height 687 + ); 688 + } 689 + 690 + #[test] 691 + fn body_has_default_margin() { 692 + let mut doc = Document::new(); 693 + let root = doc.root(); 694 + let html = doc.create_element("html"); 695 + let body = doc.create_element("body"); 696 + let p = doc.create_element("p"); 697 + let text = doc.create_text("Test"); 698 + doc.append_child(root, html); 699 + doc.append_child(html, body); 700 + doc.append_child(body, p); 701 + doc.append_child(p, text); 702 + 703 + let tree = layout_simple_html(html, &doc); 704 + let body_box = &tree.root.children[0]; 705 + 706 + assert_eq!(body_box.margin.top, 8.0); 707 + assert_eq!(body_box.margin.right, 8.0); 708 + assert_eq!(body_box.margin.bottom, 8.0); 709 + assert_eq!(body_box.margin.left, 8.0); 710 + 711 + // Body content should be offset by 8px from html content edge. 712 + assert_eq!(body_box.rect.x, 8.0); 713 + assert_eq!(body_box.rect.y, 8.0); 714 + } 715 + 716 + #[test] 717 + fn text_wraps_at_container_width() { 718 + // Use a narrow viewport to force wrapping. 719 + let mut doc = Document::new(); 720 + let root = doc.root(); 721 + let html = doc.create_element("html"); 722 + let body = doc.create_element("body"); 723 + let p = doc.create_element("p"); 724 + let text = 725 + doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); 726 + doc.append_child(root, html); 727 + doc.append_child(html, body); 728 + doc.append_child(body, p); 729 + doc.append_child(p, text); 730 + 731 + let font = test_font(); 732 + // Narrow viewport: 100px (minus body margin 8+8 = 84px content width). 733 + let tree = layout(&doc, 100.0, 600.0, &font); 734 + let body_box = &tree.root.children[0]; 735 + let p_box = &body_box.children[0]; 736 + 737 + // With a 84px content width, long text should wrap to multiple lines. 738 + assert!( 739 + p_box.lines.len() > 1, 740 + "text should wrap to multiple lines, got {} lines", 741 + p_box.lines.len() 742 + ); 743 + } 744 + 745 + #[test] 746 + fn layout_produces_positive_dimensions() { 747 + let mut doc = Document::new(); 748 + let root = doc.root(); 749 + let html = doc.create_element("html"); 750 + let body = doc.create_element("body"); 751 + let div = doc.create_element("div"); 752 + let text = doc.create_text("Content"); 753 + doc.append_child(root, html); 754 + doc.append_child(html, body); 755 + doc.append_child(body, div); 756 + doc.append_child(div, text); 757 + 758 + let tree = layout_simple_html(html, &doc); 759 + 760 + // All boxes should have non-negative dimensions. 761 + for b in tree.iter() { 762 + assert!(b.rect.width >= 0.0, "width should be >= 0"); 763 + assert!(b.rect.height >= 0.0, "height should be >= 0"); 764 + } 765 + 766 + // Overall layout should have positive height. 767 + assert!(tree.height > 0.0, "layout height should be > 0"); 768 + } 769 + 770 + #[test] 771 + fn head_is_hidden() { 772 + let mut doc = Document::new(); 773 + let root = doc.root(); 774 + let html = doc.create_element("html"); 775 + let head = doc.create_element("head"); 776 + let title = doc.create_element("title"); 777 + let title_text = doc.create_text("Page Title"); 778 + let body = doc.create_element("body"); 779 + let p = doc.create_element("p"); 780 + let p_text = doc.create_text("Visible"); 781 + doc.append_child(root, html); 782 + doc.append_child(html, head); 783 + doc.append_child(head, title); 784 + doc.append_child(title, title_text); 785 + doc.append_child(html, body); 786 + doc.append_child(body, p); 787 + doc.append_child(p, p_text); 788 + 789 + let tree = layout_simple_html(html, &doc); 790 + 791 + // html should have one child (body), head is display:none. 792 + assert_eq!( 793 + tree.root.children.len(), 794 + 1, 795 + "html should have 1 child (body), head should be hidden" 796 + ); 797 + } 798 + 799 + #[test] 800 + fn mixed_block_and_inline() { 801 + // <html><body><div>Text<p>Block</p>More</div></body></html> 802 + let mut doc = Document::new(); 803 + let root = doc.root(); 804 + let html = doc.create_element("html"); 805 + let body = doc.create_element("body"); 806 + let div = doc.create_element("div"); 807 + let text1 = doc.create_text("Text"); 808 + let p = doc.create_element("p"); 809 + let p_text = doc.create_text("Block"); 810 + let text2 = doc.create_text("More"); 811 + doc.append_child(root, html); 812 + doc.append_child(html, body); 813 + doc.append_child(body, div); 814 + doc.append_child(div, text1); 815 + doc.append_child(div, p); 816 + doc.append_child(p, p_text); 817 + doc.append_child(div, text2); 818 + 819 + let tree = layout_simple_html(html, &doc); 820 + let body_box = &tree.root.children[0]; 821 + let div_box = &body_box.children[0]; 822 + 823 + // div should have 3 children: anonymous(Text), block(p), anonymous(More). 824 + assert_eq!( 825 + div_box.children.len(), 826 + 3, 827 + "div should have 3 children (anon, block, anon), got {}", 828 + div_box.children.len() 829 + ); 830 + 831 + assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); 832 + assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); 833 + assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); 834 + } 835 + 836 + #[test] 837 + fn inline_elements_contribute_text() { 838 + // <html><body><p>Hello <em>world</em>!</p></body></html> 839 + let mut doc = Document::new(); 840 + let root = doc.root(); 841 + let html = doc.create_element("html"); 842 + let body = doc.create_element("body"); 843 + let p = doc.create_element("p"); 844 + let t1 = doc.create_text("Hello "); 845 + let em = doc.create_element("em"); 846 + let t2 = doc.create_text("world"); 847 + let t3 = doc.create_text("!"); 848 + doc.append_child(root, html); 849 + doc.append_child(html, body); 850 + doc.append_child(body, p); 851 + doc.append_child(p, t1); 852 + doc.append_child(p, em); 853 + doc.append_child(em, t2); 854 + doc.append_child(p, t3); 855 + 856 + let tree = layout_simple_html(html, &doc); 857 + let body_box = &tree.root.children[0]; 858 + let p_box = &body_box.children[0]; 859 + 860 + // Text should be collected from inline children. 861 + assert!(!p_box.lines.is_empty()); 862 + assert_eq!(p_box.lines[0].text, "Hello world!"); 863 + } 864 + 865 + #[test] 866 + fn collapse_whitespace_works() { 867 + assert_eq!(collapse_whitespace("hello world"), "hello world"); 868 + assert_eq!(collapse_whitespace(" spaces "), " spaces "); 869 + assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 870 + assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 871 + assert_eq!(collapse_whitespace(" "), " "); 872 + } 873 + 874 + #[test] 875 + fn display_type_classification() { 876 + assert_eq!(display_type("div"), DisplayType::Block); 877 + assert_eq!(display_type("p"), DisplayType::Block); 878 + assert_eq!(display_type("h1"), DisplayType::Block); 879 + assert_eq!(display_type("span"), DisplayType::Inline); 880 + assert_eq!(display_type("a"), DisplayType::Inline); 881 + assert_eq!(display_type("head"), DisplayType::None); 882 + assert_eq!(display_type("script"), DisplayType::None); 883 + assert_eq!(display_type("unknown-tag"), DisplayType::Block); 884 + } 885 + 886 + #[test] 887 + fn default_font_sizes() { 888 + assert_eq!(default_font_size("h1", 16.0), 32.0); 889 + assert_eq!(default_font_size("h2", 16.0), 24.0); 890 + assert_eq!(default_font_size("p", 16.0), 16.0); 891 + assert_eq!(default_font_size("div", 16.0), 16.0); 892 + } 893 + 894 + #[test] 895 + fn heading_margins() { 896 + let m = default_margin("h1", 32.0); 897 + let expected = 32.0 * 0.67; 898 + assert!((m.top - expected).abs() < 0.01); 899 + assert!((m.bottom - expected).abs() < 0.01); 900 + } 901 + 902 + #[test] 903 + fn layout_tree_iteration() { 904 + let mut doc = Document::new(); 905 + let root = doc.root(); 906 + let html = doc.create_element("html"); 907 + let body = doc.create_element("body"); 908 + let p = doc.create_element("p"); 909 + let text = doc.create_text("Test"); 910 + doc.append_child(root, html); 911 + doc.append_child(html, body); 912 + doc.append_child(body, p); 913 + doc.append_child(p, text); 914 + 915 + let tree = layout_simple_html(html, &doc); 916 + let count = tree.iter().count(); 917 + assert!(count >= 3, "should have at least html, body, p boxes"); 918 + } 919 + 920 + #[test] 921 + fn content_width_respects_body_margin() { 922 + let mut doc = Document::new(); 923 + let root = doc.root(); 924 + let html = doc.create_element("html"); 925 + let body = doc.create_element("body"); 926 + let div = doc.create_element("div"); 927 + let text = doc.create_text("Content"); 928 + doc.append_child(root, html); 929 + doc.append_child(html, body); 930 + doc.append_child(body, div); 931 + doc.append_child(div, text); 932 + 933 + let font = test_font(); 934 + let tree = layout(&doc, 800.0, 600.0, &font); 935 + let body_box = &tree.root.children[0]; 936 + 937 + // body content width = 800 - 8 - 8 = 784 938 + assert_eq!(body_box.rect.width, 784.0); 939 + 940 + // div inside body should also be 784px wide. 941 + let div_box = &body_box.children[0]; 942 + assert_eq!(div_box.rect.width, 784.0); 943 + } 944 + 945 + #[test] 946 + fn multiple_heading_levels() { 947 + let mut doc = Document::new(); 948 + let root = doc.root(); 949 + let html = doc.create_element("html"); 950 + let body = doc.create_element("body"); 951 + doc.append_child(root, html); 952 + doc.append_child(html, body); 953 + 954 + let tags = ["h1", "h2", "h3"]; 955 + for tag in &tags { 956 + let h = doc.create_element(tag); 957 + let t = doc.create_text(tag); 958 + doc.append_child(body, h); 959 + doc.append_child(h, t); 960 + } 961 + 962 + let tree = layout_simple_html(html, &doc); 963 + let body_box = &tree.root.children[0]; 964 + 965 + // h1 font_size > h2 font_size > h3 font_size 966 + let h1 = &body_box.children[0]; 967 + let h2 = &body_box.children[1]; 968 + let h3 = &body_box.children[2]; 969 + assert!(h1.font_size > h2.font_size); 970 + assert!(h2.font_size > h3.font_size); 971 + 972 + // All should stack vertically. 973 + assert!(h2.rect.y > h1.rect.y); 974 + assert!(h3.rect.y > h2.rect.y); 975 + } 976 + }