we (web engine): Experimental web browser project to understand the limits of Claude
at relative-positioning 1712 lines 56 kB view raw
1//! Block layout engine: box generation, block/inline layout, and text wrapping. 2//! 3//! Builds a layout tree from a styled tree (DOM + computed styles) and positions 4//! block-level elements vertically with proper inline formatting context. 5 6use std::collections::HashMap; 7 8use we_css::values::Color; 9use we_dom::{Document, NodeData, NodeId}; 10use we_style::computed::{ 11 BorderStyle, ComputedStyle, Display, LengthOrAuto, Position, StyledNode, TextAlign, 12 TextDecoration, 13}; 14use we_text::font::Font; 15 16/// Edge sizes for box model (margin, padding, border). 17#[derive(Debug, Clone, Copy, Default, PartialEq)] 18pub struct EdgeSizes { 19 pub top: f32, 20 pub right: f32, 21 pub bottom: f32, 22 pub left: f32, 23} 24 25/// A positioned rectangle with content area dimensions. 26#[derive(Debug, Clone, Copy, Default, PartialEq)] 27pub struct Rect { 28 pub x: f32, 29 pub y: f32, 30 pub width: f32, 31 pub height: f32, 32} 33 34/// The type of layout box. 35#[derive(Debug)] 36pub enum BoxType { 37 /// Block-level box from an element. 38 Block(NodeId), 39 /// Inline-level box from an element. 40 Inline(NodeId), 41 /// A run of text from a text node. 42 TextRun { node: NodeId, text: String }, 43 /// Anonymous block wrapping inline content within a block container. 44 Anonymous, 45} 46 47/// A single positioned text fragment with its own styling. 48/// 49/// Multiple fragments can share the same y-coordinate when they are 50/// on the same visual line (e.g. `<p>Hello <em>world</em></p>` produces 51/// two fragments at the same y). 52#[derive(Debug, Clone, PartialEq)] 53pub struct TextLine { 54 pub text: String, 55 pub x: f32, 56 pub y: f32, 57 pub width: f32, 58 pub font_size: f32, 59 pub color: Color, 60 pub text_decoration: TextDecoration, 61 pub background_color: Color, 62} 63 64/// A box in the layout tree with dimensions and child boxes. 65#[derive(Debug)] 66pub struct LayoutBox { 67 pub box_type: BoxType, 68 pub rect: Rect, 69 pub margin: EdgeSizes, 70 pub padding: EdgeSizes, 71 pub border: EdgeSizes, 72 pub children: Vec<LayoutBox>, 73 pub font_size: f32, 74 /// Positioned text fragments (populated for boxes with inline content). 75 pub lines: Vec<TextLine>, 76 /// Text color. 77 pub color: Color, 78 /// Background color. 79 pub background_color: Color, 80 /// Text decoration (underline, etc.). 81 pub text_decoration: TextDecoration, 82 /// Border styles (top, right, bottom, left). 83 pub border_styles: [BorderStyle; 4], 84 /// Border colors (top, right, bottom, left). 85 pub border_colors: [Color; 4], 86 /// Text alignment for this box's inline content. 87 pub text_align: TextAlign, 88 /// Computed line height in px. 89 pub line_height: f32, 90 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 91 pub replaced_size: Option<(f32, f32)>, 92 /// CSS `position` property. 93 pub position: Position, 94 /// Relative position offset (dx, dy) applied after normal flow layout. 95 pub relative_offset: (f32, f32), 96} 97 98impl LayoutBox { 99 fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 100 LayoutBox { 101 box_type, 102 rect: Rect::default(), 103 margin: EdgeSizes::default(), 104 padding: EdgeSizes::default(), 105 border: EdgeSizes::default(), 106 children: Vec::new(), 107 font_size: style.font_size, 108 lines: Vec::new(), 109 color: style.color, 110 background_color: style.background_color, 111 text_decoration: style.text_decoration, 112 border_styles: [ 113 style.border_top_style, 114 style.border_right_style, 115 style.border_bottom_style, 116 style.border_left_style, 117 ], 118 border_colors: [ 119 style.border_top_color, 120 style.border_right_color, 121 style.border_bottom_color, 122 style.border_left_color, 123 ], 124 text_align: style.text_align, 125 line_height: style.line_height, 126 replaced_size: None, 127 position: style.position, 128 relative_offset: (0.0, 0.0), 129 } 130 } 131 132 /// Total height including margin, border, and padding. 133 pub fn margin_box_height(&self) -> f32 { 134 self.margin.top 135 + self.border.top 136 + self.padding.top 137 + self.rect.height 138 + self.padding.bottom 139 + self.border.bottom 140 + self.margin.bottom 141 } 142 143 /// Iterate over all boxes in depth-first pre-order. 144 pub fn iter(&self) -> LayoutBoxIter<'_> { 145 LayoutBoxIter { stack: vec![self] } 146 } 147} 148 149/// Depth-first pre-order iterator over layout boxes. 150pub struct LayoutBoxIter<'a> { 151 stack: Vec<&'a LayoutBox>, 152} 153 154impl<'a> Iterator for LayoutBoxIter<'a> { 155 type Item = &'a LayoutBox; 156 157 fn next(&mut self) -> Option<&'a LayoutBox> { 158 let node = self.stack.pop()?; 159 for child in node.children.iter().rev() { 160 self.stack.push(child); 161 } 162 Some(node) 163 } 164} 165 166/// The result of laying out a document. 167#[derive(Debug)] 168pub struct LayoutTree { 169 pub root: LayoutBox, 170 pub width: f32, 171 pub height: f32, 172} 173 174impl LayoutTree { 175 /// Iterate over all layout boxes in depth-first pre-order. 176 pub fn iter(&self) -> LayoutBoxIter<'_> { 177 self.root.iter() 178 } 179} 180 181// --------------------------------------------------------------------------- 182// Resolve LengthOrAuto to f32 183// --------------------------------------------------------------------------- 184 185fn resolve_length(value: LengthOrAuto) -> f32 { 186 match value { 187 LengthOrAuto::Length(px) => px, 188 LengthOrAuto::Auto => 0.0, 189 } 190} 191 192/// Resolve horizontal offset for `position: relative`. 193/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). 194fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto) -> f32 { 195 match left { 196 LengthOrAuto::Length(px) => px, 197 LengthOrAuto::Auto => match right { 198 LengthOrAuto::Length(px) => -px, 199 LengthOrAuto::Auto => 0.0, 200 }, 201 } 202} 203 204/// Resolve vertical offset for `position: relative`. 205/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). 206fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto) -> f32 { 207 match top { 208 LengthOrAuto::Length(px) => px, 209 LengthOrAuto::Auto => match bottom { 210 LengthOrAuto::Length(px) => -px, 211 LengthOrAuto::Auto => 0.0, 212 }, 213 } 214} 215 216// --------------------------------------------------------------------------- 217// Build layout tree from styled tree 218// --------------------------------------------------------------------------- 219 220fn build_box( 221 styled: &StyledNode, 222 doc: &Document, 223 image_sizes: &HashMap<NodeId, (f32, f32)>, 224) -> Option<LayoutBox> { 225 let node = styled.node; 226 let style = &styled.style; 227 228 match doc.node_data(node) { 229 NodeData::Document => { 230 let mut children = Vec::new(); 231 for child in &styled.children { 232 if let Some(child_box) = build_box(child, doc, image_sizes) { 233 children.push(child_box); 234 } 235 } 236 if children.len() == 1 { 237 children.into_iter().next() 238 } else if children.is_empty() { 239 None 240 } else { 241 let mut b = LayoutBox::new(BoxType::Anonymous, style); 242 b.children = children; 243 Some(b) 244 } 245 } 246 NodeData::Element { .. } => { 247 if style.display == Display::None { 248 return None; 249 } 250 251 let margin = EdgeSizes { 252 top: resolve_length(style.margin_top), 253 right: resolve_length(style.margin_right), 254 bottom: resolve_length(style.margin_bottom), 255 left: resolve_length(style.margin_left), 256 }; 257 let padding = EdgeSizes { 258 top: style.padding_top, 259 right: style.padding_right, 260 bottom: style.padding_bottom, 261 left: style.padding_left, 262 }; 263 let border = EdgeSizes { 264 top: if style.border_top_style != BorderStyle::None { 265 style.border_top_width 266 } else { 267 0.0 268 }, 269 right: if style.border_right_style != BorderStyle::None { 270 style.border_right_width 271 } else { 272 0.0 273 }, 274 bottom: if style.border_bottom_style != BorderStyle::None { 275 style.border_bottom_width 276 } else { 277 0.0 278 }, 279 left: if style.border_left_style != BorderStyle::None { 280 style.border_left_width 281 } else { 282 0.0 283 }, 284 }; 285 286 let mut children = Vec::new(); 287 for child in &styled.children { 288 if let Some(child_box) = build_box(child, doc, image_sizes) { 289 children.push(child_box); 290 } 291 } 292 293 let box_type = match style.display { 294 Display::Block => BoxType::Block(node), 295 Display::Inline => BoxType::Inline(node), 296 Display::None => unreachable!(), 297 }; 298 299 if style.display == Display::Block { 300 children = normalize_children(children, style); 301 } 302 303 let mut b = LayoutBox::new(box_type, style); 304 b.margin = margin; 305 b.padding = padding; 306 b.border = border; 307 b.children = children; 308 309 // Check for replaced element (e.g., <img>). 310 if let Some(&(w, h)) = image_sizes.get(&node) { 311 b.replaced_size = Some((w, h)); 312 } 313 314 // Compute relative position offset. 315 if style.position == Position::Relative { 316 let dx = resolve_relative_horizontal(style.left, style.right); 317 let dy = resolve_relative_vertical(style.top, style.bottom); 318 b.relative_offset = (dx, dy); 319 } 320 321 Some(b) 322 } 323 NodeData::Text { data } => { 324 let collapsed = collapse_whitespace(data); 325 if collapsed.is_empty() { 326 return None; 327 } 328 Some(LayoutBox::new( 329 BoxType::TextRun { 330 node, 331 text: collapsed, 332 }, 333 style, 334 )) 335 } 336 NodeData::Comment { .. } => None, 337 } 338} 339 340/// Collapse runs of whitespace to a single space. 341fn collapse_whitespace(s: &str) -> String { 342 let mut result = String::new(); 343 let mut in_ws = false; 344 for ch in s.chars() { 345 if ch.is_whitespace() { 346 if !in_ws { 347 result.push(' '); 348 } 349 in_ws = true; 350 } else { 351 in_ws = false; 352 result.push(ch); 353 } 354 } 355 result 356} 357 358/// If a block container has a mix of block-level and inline-level children, 359/// wrap consecutive inline runs in anonymous block boxes. 360fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> { 361 if children.is_empty() { 362 return children; 363 } 364 365 let has_block = children.iter().any(is_block_level); 366 if !has_block { 367 return children; 368 } 369 370 let has_inline = children.iter().any(|c| !is_block_level(c)); 371 if !has_inline { 372 return children; 373 } 374 375 let mut result = Vec::new(); 376 let mut inline_group: Vec<LayoutBox> = Vec::new(); 377 378 for child in children { 379 if is_block_level(&child) { 380 if !inline_group.is_empty() { 381 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 382 anon.children = std::mem::take(&mut inline_group); 383 result.push(anon); 384 } 385 result.push(child); 386 } else { 387 inline_group.push(child); 388 } 389 } 390 391 if !inline_group.is_empty() { 392 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 393 anon.children = inline_group; 394 result.push(anon); 395 } 396 397 result 398} 399 400fn is_block_level(b: &LayoutBox) -> bool { 401 matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) 402} 403 404// --------------------------------------------------------------------------- 405// Layout algorithm 406// --------------------------------------------------------------------------- 407 408/// Position and size a layout box within `available_width` at position (`x`, `y`). 409fn compute_layout( 410 b: &mut LayoutBox, 411 x: f32, 412 y: f32, 413 available_width: f32, 414 font: &Font, 415 doc: &Document, 416) { 417 let content_x = x + b.margin.left + b.border.left + b.padding.left; 418 let content_y = y + b.margin.top + b.border.top + b.padding.top; 419 let content_width = (available_width 420 - b.margin.left 421 - b.margin.right 422 - b.border.left 423 - b.border.right 424 - b.padding.left 425 - b.padding.right) 426 .max(0.0); 427 428 b.rect.x = content_x; 429 b.rect.y = content_y; 430 b.rect.width = content_width; 431 432 // Replaced elements (e.g., <img>) have intrinsic dimensions. 433 if let Some((rw, rh)) = b.replaced_size { 434 // Use CSS width/height if specified, otherwise use replaced dimensions. 435 // Content width is the minimum of replaced width and available width. 436 b.rect.width = rw.min(content_width); 437 b.rect.height = rh; 438 apply_relative_offset(b); 439 return; 440 } 441 442 match &b.box_type { 443 BoxType::Block(_) | BoxType::Anonymous => { 444 if has_block_children(b) { 445 layout_block_children(b, font, doc); 446 } else { 447 layout_inline_children(b, font, doc); 448 } 449 } 450 BoxType::TextRun { .. } | BoxType::Inline(_) => { 451 // Handled by the parent's inline layout. 452 } 453 } 454 455 apply_relative_offset(b); 456} 457 458/// Apply `position: relative` offset to a box and all its descendants. 459/// 460/// This shifts the visual position without affecting the normal-flow layout 461/// of surrounding elements (the original space is preserved). 462fn apply_relative_offset(b: &mut LayoutBox) { 463 let (dx, dy) = b.relative_offset; 464 if dx == 0.0 && dy == 0.0 { 465 return; 466 } 467 shift_box(b, dx, dy); 468} 469 470/// Recursively shift a box and all its descendants by (dx, dy). 471fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { 472 b.rect.x += dx; 473 b.rect.y += dy; 474 for line in &mut b.lines { 475 line.x += dx; 476 line.y += dy; 477 } 478 for child in &mut b.children { 479 shift_box(child, dx, dy); 480 } 481} 482 483fn has_block_children(b: &LayoutBox) -> bool { 484 b.children.iter().any(is_block_level) 485} 486 487/// Lay out block-level children: stack them vertically. 488fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 489 let content_x = parent.rect.x; 490 let content_width = parent.rect.width; 491 let mut cursor_y = parent.rect.y; 492 493 for child in &mut parent.children { 494 compute_layout(child, content_x, cursor_y, content_width, font, doc); 495 cursor_y += child.margin_box_height(); 496 } 497 498 parent.rect.height = cursor_y - parent.rect.y; 499} 500 501// --------------------------------------------------------------------------- 502// Inline formatting context 503// --------------------------------------------------------------------------- 504 505/// An inline item produced by flattening the inline tree. 506enum InlineItemKind { 507 /// A word of text with associated styling. 508 Word { 509 text: String, 510 font_size: f32, 511 color: Color, 512 text_decoration: TextDecoration, 513 background_color: Color, 514 }, 515 /// Whitespace between words. 516 Space { font_size: f32 }, 517 /// Forced line break (`<br>`). 518 ForcedBreak, 519 /// Start of an inline box (for margin/padding/border tracking). 520 InlineStart { 521 margin_left: f32, 522 padding_left: f32, 523 border_left: f32, 524 }, 525 /// End of an inline box. 526 InlineEnd { 527 margin_right: f32, 528 padding_right: f32, 529 border_right: f32, 530 }, 531} 532 533/// A pending fragment on the current line. 534struct PendingFragment { 535 text: String, 536 x: f32, 537 width: f32, 538 font_size: f32, 539 color: Color, 540 text_decoration: TextDecoration, 541 background_color: Color, 542} 543 544/// Flatten the inline children tree into a sequence of items. 545fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec<InlineItemKind>) { 546 for child in children { 547 match &child.box_type { 548 BoxType::TextRun { text, .. } => { 549 let words = split_into_words(text); 550 for segment in words { 551 match segment { 552 WordSegment::Word(w) => { 553 items.push(InlineItemKind::Word { 554 text: w, 555 font_size: child.font_size, 556 color: child.color, 557 text_decoration: child.text_decoration, 558 background_color: child.background_color, 559 }); 560 } 561 WordSegment::Space => { 562 items.push(InlineItemKind::Space { 563 font_size: child.font_size, 564 }); 565 } 566 } 567 } 568 } 569 BoxType::Inline(node_id) => { 570 if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { 571 if tag_name == "br" { 572 items.push(InlineItemKind::ForcedBreak); 573 continue; 574 } 575 } 576 577 items.push(InlineItemKind::InlineStart { 578 margin_left: child.margin.left, 579 padding_left: child.padding.left, 580 border_left: child.border.left, 581 }); 582 583 flatten_inline_tree(&child.children, doc, items); 584 585 items.push(InlineItemKind::InlineEnd { 586 margin_right: child.margin.right, 587 padding_right: child.padding.right, 588 border_right: child.border.right, 589 }); 590 } 591 _ => {} 592 } 593 } 594} 595 596enum WordSegment { 597 Word(String), 598 Space, 599} 600 601/// Split text into alternating words and spaces. 602fn split_into_words(text: &str) -> Vec<WordSegment> { 603 let mut segments = Vec::new(); 604 let mut current_word = String::new(); 605 606 for ch in text.chars() { 607 if ch == ' ' { 608 if !current_word.is_empty() { 609 segments.push(WordSegment::Word(std::mem::take(&mut current_word))); 610 } 611 segments.push(WordSegment::Space); 612 } else { 613 current_word.push(ch); 614 } 615 } 616 617 if !current_word.is_empty() { 618 segments.push(WordSegment::Word(current_word)); 619 } 620 621 segments 622} 623 624/// Lay out inline children using a proper inline formatting context. 625fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 626 let available_width = parent.rect.width; 627 let text_align = parent.text_align; 628 let line_height = parent.line_height; 629 630 let mut items = Vec::new(); 631 flatten_inline_tree(&parent.children, doc, &mut items); 632 633 if items.is_empty() { 634 parent.rect.height = 0.0; 635 return; 636 } 637 638 // Process items into line boxes. 639 let mut all_lines: Vec<Vec<PendingFragment>> = Vec::new(); 640 let mut current_line: Vec<PendingFragment> = Vec::new(); 641 let mut cursor_x: f32 = 0.0; 642 643 for item in &items { 644 match item { 645 InlineItemKind::Word { 646 text, 647 font_size, 648 color, 649 text_decoration, 650 background_color, 651 } => { 652 let word_width = measure_text_width(font, text, *font_size); 653 654 // If this word doesn't fit and the line isn't empty, break. 655 if cursor_x > 0.0 && cursor_x + word_width > available_width { 656 all_lines.push(std::mem::take(&mut current_line)); 657 cursor_x = 0.0; 658 } 659 660 current_line.push(PendingFragment { 661 text: text.clone(), 662 x: cursor_x, 663 width: word_width, 664 font_size: *font_size, 665 color: *color, 666 text_decoration: *text_decoration, 667 background_color: *background_color, 668 }); 669 cursor_x += word_width; 670 } 671 InlineItemKind::Space { font_size } => { 672 // Only add space if we have content on the line. 673 if !current_line.is_empty() { 674 let space_width = measure_text_width(font, " ", *font_size); 675 if cursor_x + space_width <= available_width { 676 cursor_x += space_width; 677 } 678 } 679 } 680 InlineItemKind::ForcedBreak => { 681 all_lines.push(std::mem::take(&mut current_line)); 682 cursor_x = 0.0; 683 } 684 InlineItemKind::InlineStart { 685 margin_left, 686 padding_left, 687 border_left, 688 } => { 689 cursor_x += margin_left + padding_left + border_left; 690 } 691 InlineItemKind::InlineEnd { 692 margin_right, 693 padding_right, 694 border_right, 695 } => { 696 cursor_x += margin_right + padding_right + border_right; 697 } 698 } 699 } 700 701 // Flush the last line. 702 if !current_line.is_empty() { 703 all_lines.push(current_line); 704 } 705 706 if all_lines.is_empty() { 707 parent.rect.height = 0.0; 708 return; 709 } 710 711 // Position lines vertically and apply text-align. 712 let mut text_lines = Vec::new(); 713 let mut y = parent.rect.y; 714 let num_lines = all_lines.len(); 715 716 for (line_idx, line_fragments) in all_lines.iter().enumerate() { 717 if line_fragments.is_empty() { 718 y += line_height; 719 continue; 720 } 721 722 // Compute line width from last fragment. 723 let line_width = match line_fragments.last() { 724 Some(last) => last.x + last.width, 725 None => 0.0, 726 }; 727 728 // Compute text-align offset. 729 let is_last_line = line_idx == num_lines - 1; 730 let align_offset = 731 compute_align_offset(text_align, available_width, line_width, is_last_line); 732 733 for frag in line_fragments { 734 text_lines.push(TextLine { 735 text: frag.text.clone(), 736 x: parent.rect.x + frag.x + align_offset, 737 y, 738 width: frag.width, 739 font_size: frag.font_size, 740 color: frag.color, 741 text_decoration: frag.text_decoration, 742 background_color: frag.background_color, 743 }); 744 } 745 746 y += line_height; 747 } 748 749 parent.rect.height = num_lines as f32 * line_height; 750 parent.lines = text_lines; 751} 752 753/// Compute the horizontal offset for text alignment. 754fn compute_align_offset( 755 align: TextAlign, 756 available_width: f32, 757 line_width: f32, 758 is_last_line: bool, 759) -> f32 { 760 let extra_space = (available_width - line_width).max(0.0); 761 match align { 762 TextAlign::Left => 0.0, 763 TextAlign::Center => extra_space / 2.0, 764 TextAlign::Right => extra_space, 765 TextAlign::Justify => { 766 // Don't justify the last line (CSS spec behavior). 767 if is_last_line { 768 0.0 769 } else { 770 // For justify, we shift the whole line by 0 — the actual distribution 771 // of space between words would need per-word spacing. For now, treat 772 // as left-aligned; full justify support is a future enhancement. 773 0.0 774 } 775 } 776 } 777} 778 779// --------------------------------------------------------------------------- 780// Text measurement 781// --------------------------------------------------------------------------- 782 783/// Measure the total advance width of a text string at the given font size. 784fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { 785 let shaped = font.shape_text(text, font_size); 786 match shaped.last() { 787 Some(last) => last.x_offset + last.x_advance, 788 None => 0.0, 789 } 790} 791 792// --------------------------------------------------------------------------- 793// Public API 794// --------------------------------------------------------------------------- 795 796/// Build and lay out from a styled tree (produced by `resolve_styles`). 797/// 798/// Returns a `LayoutTree` with positioned boxes ready for rendering. 799pub fn layout( 800 styled_root: &StyledNode, 801 doc: &Document, 802 viewport_width: f32, 803 _viewport_height: f32, 804 font: &Font, 805 image_sizes: &HashMap<NodeId, (f32, f32)>, 806) -> LayoutTree { 807 let mut root = match build_box(styled_root, doc, image_sizes) { 808 Some(b) => b, 809 None => { 810 return LayoutTree { 811 root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()), 812 width: viewport_width, 813 height: 0.0, 814 }; 815 } 816 }; 817 818 compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc); 819 820 let height = root.margin_box_height(); 821 LayoutTree { 822 root, 823 width: viewport_width, 824 height, 825 } 826} 827 828#[cfg(test)] 829mod tests { 830 use super::*; 831 use we_dom::Document; 832 use we_style::computed::{extract_stylesheets, resolve_styles}; 833 834 fn test_font() -> Font { 835 let paths = [ 836 "/System/Library/Fonts/Geneva.ttf", 837 "/System/Library/Fonts/Monaco.ttf", 838 ]; 839 for path in &paths { 840 let p = std::path::Path::new(path); 841 if p.exists() { 842 return Font::from_file(p).expect("failed to parse font"); 843 } 844 } 845 panic!("no test font found"); 846 } 847 848 fn layout_doc(doc: &Document) -> LayoutTree { 849 let font = test_font(); 850 let sheets = extract_stylesheets(doc); 851 let styled = resolve_styles(doc, &sheets).unwrap(); 852 layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) 853 } 854 855 #[test] 856 fn empty_document() { 857 let doc = Document::new(); 858 let font = test_font(); 859 let sheets = extract_stylesheets(&doc); 860 let styled = resolve_styles(&doc, &sheets); 861 if let Some(styled) = styled { 862 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 863 assert_eq!(tree.width, 800.0); 864 } 865 } 866 867 #[test] 868 fn single_paragraph() { 869 let mut doc = Document::new(); 870 let root = doc.root(); 871 let html = doc.create_element("html"); 872 let body = doc.create_element("body"); 873 let p = doc.create_element("p"); 874 let text = doc.create_text("Hello world"); 875 doc.append_child(root, html); 876 doc.append_child(html, body); 877 doc.append_child(body, p); 878 doc.append_child(p, text); 879 880 let tree = layout_doc(&doc); 881 882 assert!(matches!(tree.root.box_type, BoxType::Block(_))); 883 884 let body_box = &tree.root.children[0]; 885 assert!(matches!(body_box.box_type, BoxType::Block(_))); 886 887 let p_box = &body_box.children[0]; 888 assert!(matches!(p_box.box_type, BoxType::Block(_))); 889 890 assert!(!p_box.lines.is_empty(), "p should have text fragments"); 891 892 // Collect all text on the first visual line. 893 let first_y = p_box.lines[0].y; 894 let line_text: String = p_box 895 .lines 896 .iter() 897 .filter(|l| (l.y - first_y).abs() < 0.01) 898 .map(|l| l.text.as_str()) 899 .collect::<Vec<_>>() 900 .join(" "); 901 assert!( 902 line_text.contains("Hello") && line_text.contains("world"), 903 "line should contain Hello and world, got: {line_text}" 904 ); 905 906 assert_eq!(p_box.margin.top, 16.0); 907 assert_eq!(p_box.margin.bottom, 16.0); 908 } 909 910 #[test] 911 fn blocks_stack_vertically() { 912 let mut doc = Document::new(); 913 let root = doc.root(); 914 let html = doc.create_element("html"); 915 let body = doc.create_element("body"); 916 let p1 = doc.create_element("p"); 917 let t1 = doc.create_text("First"); 918 let p2 = doc.create_element("p"); 919 let t2 = doc.create_text("Second"); 920 doc.append_child(root, html); 921 doc.append_child(html, body); 922 doc.append_child(body, p1); 923 doc.append_child(p1, t1); 924 doc.append_child(body, p2); 925 doc.append_child(p2, t2); 926 927 let tree = layout_doc(&doc); 928 let body_box = &tree.root.children[0]; 929 let first = &body_box.children[0]; 930 let second = &body_box.children[1]; 931 932 assert!( 933 second.rect.y > first.rect.y, 934 "second p (y={}) should be below first p (y={})", 935 second.rect.y, 936 first.rect.y 937 ); 938 } 939 940 #[test] 941 fn heading_larger_than_body() { 942 let mut doc = Document::new(); 943 let root = doc.root(); 944 let html = doc.create_element("html"); 945 let body = doc.create_element("body"); 946 let h1 = doc.create_element("h1"); 947 let h1_text = doc.create_text("Title"); 948 let p = doc.create_element("p"); 949 let p_text = doc.create_text("Text"); 950 doc.append_child(root, html); 951 doc.append_child(html, body); 952 doc.append_child(body, h1); 953 doc.append_child(h1, h1_text); 954 doc.append_child(body, p); 955 doc.append_child(p, p_text); 956 957 let tree = layout_doc(&doc); 958 let body_box = &tree.root.children[0]; 959 let h1_box = &body_box.children[0]; 960 let p_box = &body_box.children[1]; 961 962 assert!( 963 h1_box.font_size > p_box.font_size, 964 "h1 font_size ({}) should be > p font_size ({})", 965 h1_box.font_size, 966 p_box.font_size 967 ); 968 assert_eq!(h1_box.font_size, 32.0); 969 970 assert!( 971 h1_box.rect.height > p_box.rect.height, 972 "h1 height ({}) should be > p height ({})", 973 h1_box.rect.height, 974 p_box.rect.height 975 ); 976 } 977 978 #[test] 979 fn body_has_default_margin() { 980 let mut doc = Document::new(); 981 let root = doc.root(); 982 let html = doc.create_element("html"); 983 let body = doc.create_element("body"); 984 let p = doc.create_element("p"); 985 let text = doc.create_text("Test"); 986 doc.append_child(root, html); 987 doc.append_child(html, body); 988 doc.append_child(body, p); 989 doc.append_child(p, text); 990 991 let tree = layout_doc(&doc); 992 let body_box = &tree.root.children[0]; 993 994 assert_eq!(body_box.margin.top, 8.0); 995 assert_eq!(body_box.margin.right, 8.0); 996 assert_eq!(body_box.margin.bottom, 8.0); 997 assert_eq!(body_box.margin.left, 8.0); 998 999 assert_eq!(body_box.rect.x, 8.0); 1000 assert_eq!(body_box.rect.y, 8.0); 1001 } 1002 1003 #[test] 1004 fn text_wraps_at_container_width() { 1005 let mut doc = Document::new(); 1006 let root = doc.root(); 1007 let html = doc.create_element("html"); 1008 let body = doc.create_element("body"); 1009 let p = doc.create_element("p"); 1010 let text = 1011 doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); 1012 doc.append_child(root, html); 1013 doc.append_child(html, body); 1014 doc.append_child(body, p); 1015 doc.append_child(p, text); 1016 1017 let font = test_font(); 1018 let sheets = extract_stylesheets(&doc); 1019 let styled = resolve_styles(&doc, &sheets).unwrap(); 1020 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 1021 let body_box = &tree.root.children[0]; 1022 let p_box = &body_box.children[0]; 1023 1024 // Count distinct y-positions to count visual lines. 1025 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1026 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1027 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1028 1029 assert!( 1030 ys.len() > 1, 1031 "text should wrap to multiple lines, got {} visual lines", 1032 ys.len() 1033 ); 1034 } 1035 1036 #[test] 1037 fn layout_produces_positive_dimensions() { 1038 let mut doc = Document::new(); 1039 let root = doc.root(); 1040 let html = doc.create_element("html"); 1041 let body = doc.create_element("body"); 1042 let div = doc.create_element("div"); 1043 let text = doc.create_text("Content"); 1044 doc.append_child(root, html); 1045 doc.append_child(html, body); 1046 doc.append_child(body, div); 1047 doc.append_child(div, text); 1048 1049 let tree = layout_doc(&doc); 1050 1051 for b in tree.iter() { 1052 assert!(b.rect.width >= 0.0, "width should be >= 0"); 1053 assert!(b.rect.height >= 0.0, "height should be >= 0"); 1054 } 1055 1056 assert!(tree.height > 0.0, "layout height should be > 0"); 1057 } 1058 1059 #[test] 1060 fn head_is_hidden() { 1061 let mut doc = Document::new(); 1062 let root = doc.root(); 1063 let html = doc.create_element("html"); 1064 let head = doc.create_element("head"); 1065 let title = doc.create_element("title"); 1066 let title_text = doc.create_text("Page Title"); 1067 let body = doc.create_element("body"); 1068 let p = doc.create_element("p"); 1069 let p_text = doc.create_text("Visible"); 1070 doc.append_child(root, html); 1071 doc.append_child(html, head); 1072 doc.append_child(head, title); 1073 doc.append_child(title, title_text); 1074 doc.append_child(html, body); 1075 doc.append_child(body, p); 1076 doc.append_child(p, p_text); 1077 1078 let tree = layout_doc(&doc); 1079 1080 assert_eq!( 1081 tree.root.children.len(), 1082 1, 1083 "html should have 1 child (body), head should be hidden" 1084 ); 1085 } 1086 1087 #[test] 1088 fn mixed_block_and_inline() { 1089 let mut doc = Document::new(); 1090 let root = doc.root(); 1091 let html = doc.create_element("html"); 1092 let body = doc.create_element("body"); 1093 let div = doc.create_element("div"); 1094 let text1 = doc.create_text("Text"); 1095 let p = doc.create_element("p"); 1096 let p_text = doc.create_text("Block"); 1097 let text2 = doc.create_text("More"); 1098 doc.append_child(root, html); 1099 doc.append_child(html, body); 1100 doc.append_child(body, div); 1101 doc.append_child(div, text1); 1102 doc.append_child(div, p); 1103 doc.append_child(p, p_text); 1104 doc.append_child(div, text2); 1105 1106 let tree = layout_doc(&doc); 1107 let body_box = &tree.root.children[0]; 1108 let div_box = &body_box.children[0]; 1109 1110 assert_eq!( 1111 div_box.children.len(), 1112 3, 1113 "div should have 3 children (anon, block, anon), got {}", 1114 div_box.children.len() 1115 ); 1116 1117 assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); 1118 assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); 1119 assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); 1120 } 1121 1122 #[test] 1123 fn inline_elements_contribute_text() { 1124 let mut doc = Document::new(); 1125 let root = doc.root(); 1126 let html = doc.create_element("html"); 1127 let body = doc.create_element("body"); 1128 let p = doc.create_element("p"); 1129 let t1 = doc.create_text("Hello "); 1130 let em = doc.create_element("em"); 1131 let t2 = doc.create_text("world"); 1132 let t3 = doc.create_text("!"); 1133 doc.append_child(root, html); 1134 doc.append_child(html, body); 1135 doc.append_child(body, p); 1136 doc.append_child(p, t1); 1137 doc.append_child(p, em); 1138 doc.append_child(em, t2); 1139 doc.append_child(p, t3); 1140 1141 let tree = layout_doc(&doc); 1142 let body_box = &tree.root.children[0]; 1143 let p_box = &body_box.children[0]; 1144 1145 assert!(!p_box.lines.is_empty()); 1146 1147 let first_y = p_box.lines[0].y; 1148 let line_texts: Vec<&str> = p_box 1149 .lines 1150 .iter() 1151 .filter(|l| (l.y - first_y).abs() < 0.01) 1152 .map(|l| l.text.as_str()) 1153 .collect(); 1154 let combined = line_texts.join(""); 1155 assert!( 1156 combined.contains("Hello") && combined.contains("world") && combined.contains("!"), 1157 "line should contain all text, got: {combined}" 1158 ); 1159 } 1160 1161 #[test] 1162 fn collapse_whitespace_works() { 1163 assert_eq!(collapse_whitespace("hello world"), "hello world"); 1164 assert_eq!(collapse_whitespace(" spaces "), " spaces "); 1165 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 1166 assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 1167 assert_eq!(collapse_whitespace(" "), " "); 1168 } 1169 1170 #[test] 1171 fn content_width_respects_body_margin() { 1172 let mut doc = Document::new(); 1173 let root = doc.root(); 1174 let html = doc.create_element("html"); 1175 let body = doc.create_element("body"); 1176 let div = doc.create_element("div"); 1177 let text = doc.create_text("Content"); 1178 doc.append_child(root, html); 1179 doc.append_child(html, body); 1180 doc.append_child(body, div); 1181 doc.append_child(div, text); 1182 1183 let font = test_font(); 1184 let sheets = extract_stylesheets(&doc); 1185 let styled = resolve_styles(&doc, &sheets).unwrap(); 1186 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1187 let body_box = &tree.root.children[0]; 1188 1189 assert_eq!(body_box.rect.width, 784.0); 1190 1191 let div_box = &body_box.children[0]; 1192 assert_eq!(div_box.rect.width, 784.0); 1193 } 1194 1195 #[test] 1196 fn multiple_heading_levels() { 1197 let mut doc = Document::new(); 1198 let root = doc.root(); 1199 let html = doc.create_element("html"); 1200 let body = doc.create_element("body"); 1201 doc.append_child(root, html); 1202 doc.append_child(html, body); 1203 1204 let tags = ["h1", "h2", "h3"]; 1205 for tag in &tags { 1206 let h = doc.create_element(tag); 1207 let t = doc.create_text(tag); 1208 doc.append_child(body, h); 1209 doc.append_child(h, t); 1210 } 1211 1212 let tree = layout_doc(&doc); 1213 let body_box = &tree.root.children[0]; 1214 1215 let h1 = &body_box.children[0]; 1216 let h2 = &body_box.children[1]; 1217 let h3 = &body_box.children[2]; 1218 assert!(h1.font_size > h2.font_size); 1219 assert!(h2.font_size > h3.font_size); 1220 1221 assert!(h2.rect.y > h1.rect.y); 1222 assert!(h3.rect.y > h2.rect.y); 1223 } 1224 1225 #[test] 1226 fn layout_tree_iteration() { 1227 let mut doc = Document::new(); 1228 let root = doc.root(); 1229 let html = doc.create_element("html"); 1230 let body = doc.create_element("body"); 1231 let p = doc.create_element("p"); 1232 let text = doc.create_text("Test"); 1233 doc.append_child(root, html); 1234 doc.append_child(html, body); 1235 doc.append_child(body, p); 1236 doc.append_child(p, text); 1237 1238 let tree = layout_doc(&doc); 1239 let count = tree.iter().count(); 1240 assert!(count >= 3, "should have at least html, body, p boxes"); 1241 } 1242 1243 #[test] 1244 fn css_style_affects_layout() { 1245 let html_str = r#"<!DOCTYPE html> 1246<html> 1247<head> 1248<style> 1249p { margin-top: 50px; margin-bottom: 50px; } 1250</style> 1251</head> 1252<body> 1253<p>First</p> 1254<p>Second</p> 1255</body> 1256</html>"#; 1257 let doc = we_html::parse_html(html_str); 1258 let font = test_font(); 1259 let sheets = extract_stylesheets(&doc); 1260 let styled = resolve_styles(&doc, &sheets).unwrap(); 1261 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1262 1263 let body_box = &tree.root.children[0]; 1264 let first = &body_box.children[0]; 1265 let second = &body_box.children[1]; 1266 1267 assert_eq!(first.margin.top, 50.0); 1268 assert_eq!(first.margin.bottom, 50.0); 1269 1270 assert!(second.rect.y > first.rect.y + 100.0); 1271 } 1272 1273 #[test] 1274 fn inline_style_affects_layout() { 1275 let html_str = r#"<!DOCTYPE html> 1276<html> 1277<body> 1278<div style="padding-top: 20px; padding-bottom: 20px;"> 1279<p>Content</p> 1280</div> 1281</body> 1282</html>"#; 1283 let doc = we_html::parse_html(html_str); 1284 let font = test_font(); 1285 let sheets = extract_stylesheets(&doc); 1286 let styled = resolve_styles(&doc, &sheets).unwrap(); 1287 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1288 1289 let body_box = &tree.root.children[0]; 1290 let div_box = &body_box.children[0]; 1291 1292 assert_eq!(div_box.padding.top, 20.0); 1293 assert_eq!(div_box.padding.bottom, 20.0); 1294 } 1295 1296 #[test] 1297 fn css_color_propagates_to_layout() { 1298 let html_str = r#"<!DOCTYPE html> 1299<html> 1300<head><style>p { color: red; background-color: blue; }</style></head> 1301<body><p>Colored</p></body> 1302</html>"#; 1303 let doc = we_html::parse_html(html_str); 1304 let font = test_font(); 1305 let sheets = extract_stylesheets(&doc); 1306 let styled = resolve_styles(&doc, &sheets).unwrap(); 1307 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1308 1309 let body_box = &tree.root.children[0]; 1310 let p_box = &body_box.children[0]; 1311 1312 assert_eq!(p_box.color, Color::rgb(255, 0, 0)); 1313 assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); 1314 } 1315 1316 // --- New inline layout tests --- 1317 1318 #[test] 1319 fn inline_elements_have_per_fragment_styling() { 1320 let html_str = r#"<!DOCTYPE html> 1321<html> 1322<head><style>em { color: red; }</style></head> 1323<body><p>Hello <em>world</em></p></body> 1324</html>"#; 1325 let doc = we_html::parse_html(html_str); 1326 let font = test_font(); 1327 let sheets = extract_stylesheets(&doc); 1328 let styled = resolve_styles(&doc, &sheets).unwrap(); 1329 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1330 1331 let body_box = &tree.root.children[0]; 1332 let p_box = &body_box.children[0]; 1333 1334 let colors: Vec<Color> = p_box.lines.iter().map(|l| l.color).collect(); 1335 assert!( 1336 colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), 1337 "should have black text" 1338 ); 1339 assert!( 1340 colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), 1341 "should have red text from <em>" 1342 ); 1343 } 1344 1345 #[test] 1346 fn br_element_forces_line_break() { 1347 let html_str = r#"<!DOCTYPE html> 1348<html> 1349<body><p>Line one<br>Line two</p></body> 1350</html>"#; 1351 let doc = we_html::parse_html(html_str); 1352 let font = test_font(); 1353 let sheets = extract_stylesheets(&doc); 1354 let styled = resolve_styles(&doc, &sheets).unwrap(); 1355 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1356 1357 let body_box = &tree.root.children[0]; 1358 let p_box = &body_box.children[0]; 1359 1360 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1361 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1362 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1363 1364 assert!( 1365 ys.len() >= 2, 1366 "<br> should produce 2 visual lines, got {}", 1367 ys.len() 1368 ); 1369 } 1370 1371 #[test] 1372 fn text_align_center() { 1373 let html_str = r#"<!DOCTYPE html> 1374<html> 1375<head><style>p { text-align: center; }</style></head> 1376<body><p>Hi</p></body> 1377</html>"#; 1378 let doc = we_html::parse_html(html_str); 1379 let font = test_font(); 1380 let sheets = extract_stylesheets(&doc); 1381 let styled = resolve_styles(&doc, &sheets).unwrap(); 1382 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1383 1384 let body_box = &tree.root.children[0]; 1385 let p_box = &body_box.children[0]; 1386 1387 assert!(!p_box.lines.is_empty()); 1388 let first = &p_box.lines[0]; 1389 // Center-aligned: text should be noticeably offset from content x. 1390 assert!( 1391 first.x > p_box.rect.x + 10.0, 1392 "center-aligned text x ({}) should be offset from content x ({})", 1393 first.x, 1394 p_box.rect.x 1395 ); 1396 } 1397 1398 #[test] 1399 fn text_align_right() { 1400 let html_str = r#"<!DOCTYPE html> 1401<html> 1402<head><style>p { text-align: right; }</style></head> 1403<body><p>Hi</p></body> 1404</html>"#; 1405 let doc = we_html::parse_html(html_str); 1406 let font = test_font(); 1407 let sheets = extract_stylesheets(&doc); 1408 let styled = resolve_styles(&doc, &sheets).unwrap(); 1409 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1410 1411 let body_box = &tree.root.children[0]; 1412 let p_box = &body_box.children[0]; 1413 1414 assert!(!p_box.lines.is_empty()); 1415 let first = &p_box.lines[0]; 1416 let right_edge = p_box.rect.x + p_box.rect.width; 1417 assert!( 1418 (first.x + first.width - right_edge).abs() < 1.0, 1419 "right-aligned text end ({}) should be near right edge ({})", 1420 first.x + first.width, 1421 right_edge 1422 ); 1423 } 1424 1425 #[test] 1426 fn inline_padding_offsets_text() { 1427 let html_str = r#"<!DOCTYPE html> 1428<html> 1429<head><style>span { padding-left: 20px; padding-right: 20px; }</style></head> 1430<body><p>A<span>B</span>C</p></body> 1431</html>"#; 1432 let doc = we_html::parse_html(html_str); 1433 let font = test_font(); 1434 let sheets = extract_stylesheets(&doc); 1435 let styled = resolve_styles(&doc, &sheets).unwrap(); 1436 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1437 1438 let body_box = &tree.root.children[0]; 1439 let p_box = &body_box.children[0]; 1440 1441 // Should have at least 3 fragments: A, B, C 1442 assert!( 1443 p_box.lines.len() >= 3, 1444 "should have fragments for A, B, C, got {}", 1445 p_box.lines.len() 1446 ); 1447 1448 // B should be offset by the span's padding. 1449 let a_frag = &p_box.lines[0]; 1450 let b_frag = &p_box.lines[1]; 1451 let gap = b_frag.x - (a_frag.x + a_frag.width); 1452 // Gap should include the 20px padding-left from the span. 1453 assert!( 1454 gap >= 19.0, 1455 "gap between A and B ({gap}) should include span padding-left (20px)" 1456 ); 1457 } 1458 1459 #[test] 1460 fn text_fragments_have_correct_font_size() { 1461 let html_str = r#"<!DOCTYPE html> 1462<html> 1463<body><h1>Big</h1><p>Small</p></body> 1464</html>"#; 1465 let doc = we_html::parse_html(html_str); 1466 let font = test_font(); 1467 let sheets = extract_stylesheets(&doc); 1468 let styled = resolve_styles(&doc, &sheets).unwrap(); 1469 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1470 1471 let body_box = &tree.root.children[0]; 1472 let h1_box = &body_box.children[0]; 1473 let p_box = &body_box.children[1]; 1474 1475 assert!(!h1_box.lines.is_empty()); 1476 assert!(!p_box.lines.is_empty()); 1477 assert_eq!(h1_box.lines[0].font_size, 32.0); 1478 assert_eq!(p_box.lines[0].font_size, 16.0); 1479 } 1480 1481 #[test] 1482 fn line_height_from_computed_style() { 1483 let html_str = r#"<!DOCTYPE html> 1484<html> 1485<head><style>p { line-height: 30px; }</style></head> 1486<body><p>Line one Line two Line three</p></body> 1487</html>"#; 1488 let doc = we_html::parse_html(html_str); 1489 let font = test_font(); 1490 let sheets = extract_stylesheets(&doc); 1491 let styled = resolve_styles(&doc, &sheets).unwrap(); 1492 // Narrow viewport to force wrapping. 1493 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 1494 1495 let body_box = &tree.root.children[0]; 1496 let p_box = &body_box.children[0]; 1497 1498 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 1499 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 1500 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 1501 1502 if ys.len() >= 2 { 1503 let gap = ys[1] - ys[0]; 1504 assert!( 1505 (gap - 30.0).abs() < 1.0, 1506 "line spacing ({gap}) should be ~30px from line-height" 1507 ); 1508 } 1509 } 1510 1511 // --- Relative positioning tests --- 1512 1513 #[test] 1514 fn relative_position_top_left() { 1515 let html_str = r#"<!DOCTYPE html> 1516<html> 1517<body> 1518<div style="position: relative; top: 10px; left: 20px;">Content</div> 1519</body> 1520</html>"#; 1521 let doc = we_html::parse_html(html_str); 1522 let font = test_font(); 1523 let sheets = extract_stylesheets(&doc); 1524 let styled = resolve_styles(&doc, &sheets).unwrap(); 1525 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1526 1527 let body_box = &tree.root.children[0]; 1528 let div_box = &body_box.children[0]; 1529 1530 assert_eq!(div_box.position, Position::Relative); 1531 assert_eq!(div_box.relative_offset, (20.0, 10.0)); 1532 1533 // The div should be shifted from where it would be in normal flow. 1534 // Normal flow position: body.rect.x + margin, body.rect.y + margin. 1535 // With relative offset: shifted by (20, 10). 1536 // Body has 8px margin by default, so content starts at x=8, y=8. 1537 assert!( 1538 (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, 1539 "div x ({}) should be 28.0 (8 + 20)", 1540 div_box.rect.x 1541 ); 1542 assert!( 1543 (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, 1544 "div y ({}) should be 18.0 (8 + 10)", 1545 div_box.rect.y 1546 ); 1547 } 1548 1549 #[test] 1550 fn relative_position_does_not_affect_siblings() { 1551 let html_str = r#"<!DOCTYPE html> 1552<html> 1553<head><style> 1554p { margin: 0; } 1555</style></head> 1556<body> 1557<p id="first" style="position: relative; top: 50px;">First</p> 1558<p id="second">Second</p> 1559</body> 1560</html>"#; 1561 let doc = we_html::parse_html(html_str); 1562 let font = test_font(); 1563 let sheets = extract_stylesheets(&doc); 1564 let styled = resolve_styles(&doc, &sheets).unwrap(); 1565 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1566 1567 let body_box = &tree.root.children[0]; 1568 let first = &body_box.children[0]; 1569 let second = &body_box.children[1]; 1570 1571 // The first paragraph is shifted down by 50px visually. 1572 assert_eq!(first.relative_offset, (0.0, 50.0)); 1573 1574 // But the second paragraph should be at its normal-flow position, 1575 // as if the first paragraph were NOT shifted. The second paragraph 1576 // should come right after the first's normal-flow height. 1577 // Body content starts at y=8 (default body margin). First p has 0 margin. 1578 // Second p should start right after first p's height (without offset). 1579 let first_normal_y = 8.0; // body margin 1580 let first_height = first.rect.height; 1581 let expected_second_y = first_normal_y + first_height; 1582 assert!( 1583 (second.rect.y - expected_second_y).abs() < 1.0, 1584 "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", 1585 second.rect.y 1586 ); 1587 } 1588 1589 #[test] 1590 fn relative_position_conflicting_offsets() { 1591 // When both top and bottom are specified, top wins. 1592 // When both left and right are specified, left wins. 1593 let html_str = r#"<!DOCTYPE html> 1594<html> 1595<body> 1596<div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div> 1597</body> 1598</html>"#; 1599 let doc = we_html::parse_html(html_str); 1600 let font = test_font(); 1601 let sheets = extract_stylesheets(&doc); 1602 let styled = resolve_styles(&doc, &sheets).unwrap(); 1603 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1604 1605 let body_box = &tree.root.children[0]; 1606 let div_box = &body_box.children[0]; 1607 1608 // top wins over bottom: dy = 10 (not -20) 1609 // left wins over right: dx = 30 (not -40) 1610 assert_eq!(div_box.relative_offset, (30.0, 10.0)); 1611 } 1612 1613 #[test] 1614 fn relative_position_auto_offsets() { 1615 // auto offsets should resolve to 0 (no movement). 1616 let html_str = r#"<!DOCTYPE html> 1617<html> 1618<body> 1619<div style="position: relative;">Content</div> 1620</body> 1621</html>"#; 1622 let doc = we_html::parse_html(html_str); 1623 let font = test_font(); 1624 let sheets = extract_stylesheets(&doc); 1625 let styled = resolve_styles(&doc, &sheets).unwrap(); 1626 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1627 1628 let body_box = &tree.root.children[0]; 1629 let div_box = &body_box.children[0]; 1630 1631 assert_eq!(div_box.position, Position::Relative); 1632 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1633 } 1634 1635 #[test] 1636 fn relative_position_bottom_right() { 1637 // bottom: 15px should shift up by 15px (negative direction). 1638 // right: 25px should shift left by 25px (negative direction). 1639 let html_str = r#"<!DOCTYPE html> 1640<html> 1641<body> 1642<div style="position: relative; bottom: 15px; right: 25px;">Content</div> 1643</body> 1644</html>"#; 1645 let doc = we_html::parse_html(html_str); 1646 let font = test_font(); 1647 let sheets = extract_stylesheets(&doc); 1648 let styled = resolve_styles(&doc, &sheets).unwrap(); 1649 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1650 1651 let body_box = &tree.root.children[0]; 1652 let div_box = &body_box.children[0]; 1653 1654 assert_eq!(div_box.relative_offset, (-25.0, -15.0)); 1655 } 1656 1657 #[test] 1658 fn relative_position_shifts_text_lines() { 1659 let html_str = r#"<!DOCTYPE html> 1660<html> 1661<head><style>p { margin: 0; }</style></head> 1662<body> 1663<p style="position: relative; top: 30px; left: 40px;">Hello</p> 1664</body> 1665</html>"#; 1666 let doc = we_html::parse_html(html_str); 1667 let font = test_font(); 1668 let sheets = extract_stylesheets(&doc); 1669 let styled = resolve_styles(&doc, &sheets).unwrap(); 1670 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1671 1672 let body_box = &tree.root.children[0]; 1673 let p_box = &body_box.children[0]; 1674 1675 assert!(!p_box.lines.is_empty(), "p should have text lines"); 1676 let first_line = &p_box.lines[0]; 1677 1678 // Text should be shifted by the relative offset. 1679 // Body content starts at x=8, y=8. With offset: x=48, y=38. 1680 assert!( 1681 first_line.x >= 8.0 + 40.0 - 1.0, 1682 "text x ({}) should be shifted by left offset", 1683 first_line.x 1684 ); 1685 assert!( 1686 first_line.y >= 8.0 + 30.0 - 1.0, 1687 "text y ({}) should be shifted by top offset", 1688 first_line.y 1689 ); 1690 } 1691 1692 #[test] 1693 fn static_position_has_no_offset() { 1694 let html_str = r#"<!DOCTYPE html> 1695<html> 1696<body> 1697<div>Normal flow</div> 1698</body> 1699</html>"#; 1700 let doc = we_html::parse_html(html_str); 1701 let font = test_font(); 1702 let sheets = extract_stylesheets(&doc); 1703 let styled = resolve_styles(&doc, &sheets).unwrap(); 1704 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1705 1706 let body_box = &tree.root.children[0]; 1707 let div_box = &body_box.children[0]; 1708 1709 assert_eq!(div_box.position, Position::Static); 1710 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1711 } 1712}