we (web engine): Experimental web browser project to understand the limits of Claude
at absolute-fixed 4619 lines 158 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 AlignContent, AlignItems, AlignSelf, BorderStyle, BoxSizing, ComputedStyle, Display, 12 FlexDirection, FlexWrap, JustifyContent, LengthOrAuto, Overflow, Position, StyledNode, 13 TextAlign, TextDecoration, Visibility, 14}; 15use we_text::font::Font; 16 17/// Width of scroll bars in pixels. 18pub const SCROLLBAR_WIDTH: f32 = 15.0; 19 20/// Edge sizes for box model (margin, padding, border). 21#[derive(Debug, Clone, Copy, Default, PartialEq)] 22pub struct EdgeSizes { 23 pub top: f32, 24 pub right: f32, 25 pub bottom: f32, 26 pub left: f32, 27} 28 29/// A positioned rectangle with content area dimensions. 30#[derive(Debug, Clone, Copy, Default, PartialEq)] 31pub struct Rect { 32 pub x: f32, 33 pub y: f32, 34 pub width: f32, 35 pub height: f32, 36} 37 38/// The type of layout box. 39#[derive(Debug)] 40pub enum BoxType { 41 /// Block-level box from an element. 42 Block(NodeId), 43 /// Inline-level box from an element. 44 Inline(NodeId), 45 /// A run of text from a text node. 46 TextRun { node: NodeId, text: String }, 47 /// Anonymous block wrapping inline content within a block container. 48 Anonymous, 49} 50 51/// A single positioned text fragment with its own styling. 52/// 53/// Multiple fragments can share the same y-coordinate when they are 54/// on the same visual line (e.g. `<p>Hello <em>world</em></p>` produces 55/// two fragments at the same y). 56#[derive(Debug, Clone, PartialEq)] 57pub struct TextLine { 58 pub text: String, 59 pub x: f32, 60 pub y: f32, 61 pub width: f32, 62 pub font_size: f32, 63 pub color: Color, 64 pub text_decoration: TextDecoration, 65 pub background_color: Color, 66} 67 68/// A box in the layout tree with dimensions and child boxes. 69#[derive(Debug)] 70pub struct LayoutBox { 71 pub box_type: BoxType, 72 pub display: Display, 73 pub rect: Rect, 74 pub margin: EdgeSizes, 75 pub padding: EdgeSizes, 76 pub border: EdgeSizes, 77 pub children: Vec<LayoutBox>, 78 pub font_size: f32, 79 /// Positioned text fragments (populated for boxes with inline content). 80 pub lines: Vec<TextLine>, 81 /// Text color. 82 pub color: Color, 83 /// Background color. 84 pub background_color: Color, 85 /// Text decoration (underline, etc.). 86 pub text_decoration: TextDecoration, 87 /// Border styles (top, right, bottom, left). 88 pub border_styles: [BorderStyle; 4], 89 /// Border colors (top, right, bottom, left). 90 pub border_colors: [Color; 4], 91 /// Text alignment for this box's inline content. 92 pub text_align: TextAlign, 93 /// Computed line height in px. 94 pub line_height: f32, 95 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 96 pub replaced_size: Option<(f32, f32)>, 97 /// CSS `position` property. 98 pub position: Position, 99 /// CSS `z-index` property (None = auto). 100 pub z_index: Option<i32>, 101 /// Relative position offset (dx, dy) applied after normal flow layout. 102 pub relative_offset: (f32, f32), 103 /// CSS `overflow` property. 104 pub overflow: Overflow, 105 /// CSS `box-sizing` property. 106 pub box_sizing: BoxSizing, 107 /// CSS `width` property (explicit or auto, may contain percentage). 108 pub css_width: LengthOrAuto, 109 /// CSS `height` property (explicit or auto, may contain percentage). 110 pub css_height: LengthOrAuto, 111 /// CSS margin values (may contain percentages for layout resolution). 112 pub css_margin: [LengthOrAuto; 4], 113 /// CSS padding values (may contain percentages for layout resolution). 114 pub css_padding: [LengthOrAuto; 4], 115 /// CSS position offset values (top, right, bottom, left) for relative positioning. 116 pub css_offsets: [LengthOrAuto; 4], 117 /// CSS `visibility` property. 118 pub visibility: Visibility, 119 /// Natural content height before CSS height override. 120 /// Used to determine overflow for scroll containers. 121 pub content_height: f32, 122 // Flex container properties 123 pub flex_direction: FlexDirection, 124 pub flex_wrap: FlexWrap, 125 pub justify_content: JustifyContent, 126 pub align_items: AlignItems, 127 pub align_content: AlignContent, 128 pub row_gap: f32, 129 pub column_gap: f32, 130 // Flex item properties 131 pub flex_grow: f32, 132 pub flex_shrink: f32, 133 pub flex_basis: LengthOrAuto, 134 pub align_self: AlignSelf, 135 pub order: i32, 136} 137 138impl LayoutBox { 139 fn new(box_type: BoxType, style: &ComputedStyle) -> Self { 140 LayoutBox { 141 box_type, 142 display: style.display, 143 rect: Rect::default(), 144 margin: EdgeSizes::default(), 145 padding: EdgeSizes::default(), 146 border: EdgeSizes::default(), 147 children: Vec::new(), 148 font_size: style.font_size, 149 lines: Vec::new(), 150 color: style.color, 151 background_color: style.background_color, 152 text_decoration: style.text_decoration, 153 border_styles: [ 154 style.border_top_style, 155 style.border_right_style, 156 style.border_bottom_style, 157 style.border_left_style, 158 ], 159 border_colors: [ 160 style.border_top_color, 161 style.border_right_color, 162 style.border_bottom_color, 163 style.border_left_color, 164 ], 165 text_align: style.text_align, 166 line_height: style.line_height, 167 replaced_size: None, 168 position: style.position, 169 z_index: style.z_index, 170 relative_offset: (0.0, 0.0), 171 overflow: style.overflow, 172 box_sizing: style.box_sizing, 173 css_width: style.width, 174 css_height: style.height, 175 css_margin: [ 176 style.margin_top, 177 style.margin_right, 178 style.margin_bottom, 179 style.margin_left, 180 ], 181 css_padding: [ 182 style.padding_top, 183 style.padding_right, 184 style.padding_bottom, 185 style.padding_left, 186 ], 187 css_offsets: [style.top, style.right, style.bottom, style.left], 188 visibility: style.visibility, 189 content_height: 0.0, 190 flex_direction: style.flex_direction, 191 flex_wrap: style.flex_wrap, 192 justify_content: style.justify_content, 193 align_items: style.align_items, 194 align_content: style.align_content, 195 row_gap: style.row_gap, 196 column_gap: style.column_gap, 197 flex_grow: style.flex_grow, 198 flex_shrink: style.flex_shrink, 199 flex_basis: style.flex_basis, 200 align_self: style.align_self, 201 order: style.order, 202 } 203 } 204 205 /// Total height including margin, border, and padding. 206 pub fn margin_box_height(&self) -> f32 { 207 self.margin.top 208 + self.border.top 209 + self.padding.top 210 + self.rect.height 211 + self.padding.bottom 212 + self.border.bottom 213 + self.margin.bottom 214 } 215 216 /// Iterate over all boxes in depth-first pre-order. 217 pub fn iter(&self) -> LayoutBoxIter<'_> { 218 LayoutBoxIter { stack: vec![self] } 219 } 220} 221 222/// Depth-first pre-order iterator over layout boxes. 223pub struct LayoutBoxIter<'a> { 224 stack: Vec<&'a LayoutBox>, 225} 226 227impl<'a> Iterator for LayoutBoxIter<'a> { 228 type Item = &'a LayoutBox; 229 230 fn next(&mut self) -> Option<&'a LayoutBox> { 231 let node = self.stack.pop()?; 232 for child in node.children.iter().rev() { 233 self.stack.push(child); 234 } 235 Some(node) 236 } 237} 238 239/// The result of laying out a document. 240#[derive(Debug)] 241pub struct LayoutTree { 242 pub root: LayoutBox, 243 pub width: f32, 244 pub height: f32, 245} 246 247impl LayoutTree { 248 /// Iterate over all layout boxes in depth-first pre-order. 249 pub fn iter(&self) -> LayoutBoxIter<'_> { 250 self.root.iter() 251 } 252} 253 254// --------------------------------------------------------------------------- 255// Resolve LengthOrAuto to f32 256// --------------------------------------------------------------------------- 257 258/// Resolve a `LengthOrAuto` to px. Percentages are resolved against 259/// `reference` (typically the containing block width). Auto resolves to 0. 260fn resolve_length_against(value: LengthOrAuto, reference: f32) -> f32 { 261 match value { 262 LengthOrAuto::Length(px) => px, 263 LengthOrAuto::Percentage(p) => p / 100.0 * reference, 264 LengthOrAuto::Auto => 0.0, 265 } 266} 267 268/// Resolve horizontal offset for `position: relative`. 269/// If both `left` and `right` are specified, `left` wins (CSS2 §9.4.3, ltr). 270fn resolve_relative_horizontal(left: LengthOrAuto, right: LengthOrAuto, cb_width: f32) -> f32 { 271 match left { 272 LengthOrAuto::Length(px) => px, 273 LengthOrAuto::Percentage(p) => p / 100.0 * cb_width, 274 LengthOrAuto::Auto => match right { 275 LengthOrAuto::Length(px) => -px, 276 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_width), 277 LengthOrAuto::Auto => 0.0, 278 }, 279 } 280} 281 282/// Resolve vertical offset for `position: relative`. 283/// If both `top` and `bottom` are specified, `top` wins (CSS2 §9.4.3). 284fn resolve_relative_vertical(top: LengthOrAuto, bottom: LengthOrAuto, cb_height: f32) -> f32 { 285 match top { 286 LengthOrAuto::Length(px) => px, 287 LengthOrAuto::Percentage(p) => p / 100.0 * cb_height, 288 LengthOrAuto::Auto => match bottom { 289 LengthOrAuto::Length(px) => -px, 290 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_height), 291 LengthOrAuto::Auto => 0.0, 292 }, 293 } 294} 295 296// --------------------------------------------------------------------------- 297// Build layout tree from styled tree 298// --------------------------------------------------------------------------- 299 300fn build_box( 301 styled: &StyledNode, 302 doc: &Document, 303 image_sizes: &HashMap<NodeId, (f32, f32)>, 304) -> Option<LayoutBox> { 305 let node = styled.node; 306 let style = &styled.style; 307 308 match doc.node_data(node) { 309 NodeData::Document => { 310 let mut children = Vec::new(); 311 for child in &styled.children { 312 if let Some(child_box) = build_box(child, doc, image_sizes) { 313 children.push(child_box); 314 } 315 } 316 if children.len() == 1 { 317 children.into_iter().next() 318 } else if children.is_empty() { 319 None 320 } else { 321 let mut b = LayoutBox::new(BoxType::Anonymous, style); 322 b.children = children; 323 Some(b) 324 } 325 } 326 NodeData::Element { .. } => { 327 if style.display == Display::None { 328 return None; 329 } 330 331 // Margin and padding: resolve absolute lengths now; percentages 332 // will be re-resolved in compute_layout against containing block. 333 // Use 0.0 as a placeholder reference for percentages — they'll be 334 // resolved properly in compute_layout. 335 let margin = EdgeSizes { 336 top: resolve_length_against(style.margin_top, 0.0), 337 right: resolve_length_against(style.margin_right, 0.0), 338 bottom: resolve_length_against(style.margin_bottom, 0.0), 339 left: resolve_length_against(style.margin_left, 0.0), 340 }; 341 let padding = EdgeSizes { 342 top: resolve_length_against(style.padding_top, 0.0), 343 right: resolve_length_against(style.padding_right, 0.0), 344 bottom: resolve_length_against(style.padding_bottom, 0.0), 345 left: resolve_length_against(style.padding_left, 0.0), 346 }; 347 let border = EdgeSizes { 348 top: if style.border_top_style != BorderStyle::None { 349 style.border_top_width 350 } else { 351 0.0 352 }, 353 right: if style.border_right_style != BorderStyle::None { 354 style.border_right_width 355 } else { 356 0.0 357 }, 358 bottom: if style.border_bottom_style != BorderStyle::None { 359 style.border_bottom_width 360 } else { 361 0.0 362 }, 363 left: if style.border_left_style != BorderStyle::None { 364 style.border_left_width 365 } else { 366 0.0 367 }, 368 }; 369 370 let mut children = Vec::new(); 371 for child in &styled.children { 372 if let Some(child_box) = build_box(child, doc, image_sizes) { 373 children.push(child_box); 374 } 375 } 376 377 let box_type = match style.display { 378 Display::Block | Display::Flex | Display::InlineFlex => BoxType::Block(node), 379 Display::Inline => BoxType::Inline(node), 380 Display::None => unreachable!(), 381 }; 382 383 if style.display == Display::Block { 384 children = normalize_children(children, style); 385 } 386 387 let mut b = LayoutBox::new(box_type, style); 388 b.margin = margin; 389 b.padding = padding; 390 b.border = border; 391 b.children = children; 392 393 // Check for replaced element (e.g., <img>). 394 if let Some(&(w, h)) = image_sizes.get(&node) { 395 b.replaced_size = Some((w, h)); 396 } 397 398 // Relative position offsets are resolved in compute_layout 399 // where the containing block dimensions are known. 400 401 Some(b) 402 } 403 NodeData::Text { data } => { 404 let collapsed = collapse_whitespace(data); 405 if collapsed.is_empty() { 406 return None; 407 } 408 Some(LayoutBox::new( 409 BoxType::TextRun { 410 node, 411 text: collapsed, 412 }, 413 style, 414 )) 415 } 416 NodeData::Comment { .. } => None, 417 } 418} 419 420/// Collapse runs of whitespace to a single space. 421fn collapse_whitespace(s: &str) -> String { 422 let mut result = String::new(); 423 let mut in_ws = false; 424 for ch in s.chars() { 425 if ch.is_whitespace() { 426 if !in_ws { 427 result.push(' '); 428 } 429 in_ws = true; 430 } else { 431 in_ws = false; 432 result.push(ch); 433 } 434 } 435 result 436} 437 438/// If a block container has a mix of block-level and inline-level children, 439/// wrap consecutive inline runs in anonymous block boxes. 440/// Out-of-flow elements (absolute/fixed) are excluded from the determination 441/// and placed at the top level without affecting normalization. 442fn normalize_children(children: Vec<LayoutBox>, parent_style: &ComputedStyle) -> Vec<LayoutBox> { 443 if children.is_empty() { 444 return children; 445 } 446 447 // Only consider in-flow children for block/inline normalization. 448 let has_block = children.iter().any(|c| is_in_flow(c) && is_block_level(c)); 449 if !has_block { 450 return children; 451 } 452 453 let has_inline = children.iter().any(|c| is_in_flow(c) && !is_block_level(c)); 454 if !has_inline { 455 return children; 456 } 457 458 let mut result = Vec::new(); 459 let mut inline_group: Vec<LayoutBox> = Vec::new(); 460 461 for child in children { 462 if !is_in_flow(&child) || is_block_level(&child) { 463 // Out-of-flow or block-level: flush any pending inline group first. 464 if !inline_group.is_empty() { 465 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 466 anon.children = std::mem::take(&mut inline_group); 467 result.push(anon); 468 } 469 result.push(child); 470 } else { 471 inline_group.push(child); 472 } 473 } 474 475 if !inline_group.is_empty() { 476 let mut anon = LayoutBox::new(BoxType::Anonymous, parent_style); 477 anon.children = inline_group; 478 result.push(anon); 479 } 480 481 result 482} 483 484fn is_block_level(b: &LayoutBox) -> bool { 485 matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) 486} 487 488/// Returns `true` if this box is in normal flow (not absolutely or fixed positioned). 489fn is_in_flow(b: &LayoutBox) -> bool { 490 b.position != Position::Absolute && b.position != Position::Fixed 491} 492 493// --------------------------------------------------------------------------- 494// Layout algorithm 495// --------------------------------------------------------------------------- 496 497/// Position and size a layout box within `available_width` at position (`x`, `y`). 498/// 499/// `available_width` is the containing block width — used as the reference for 500/// percentage widths, margins, and paddings (per CSS spec, even vertical margins/ 501/// padding resolve against the containing block width). 502/// 503/// `abs_cb` is the padding box of the nearest positioned ancestor, used as the 504/// containing block for absolutely positioned descendants. 505#[allow(clippy::too_many_arguments)] 506fn compute_layout( 507 b: &mut LayoutBox, 508 x: f32, 509 y: f32, 510 available_width: f32, 511 viewport_width: f32, 512 viewport_height: f32, 513 font: &Font, 514 doc: &Document, 515 abs_cb: Rect, 516) { 517 // Resolve percentage margins against containing block width. 518 // Only re-resolve percentages — absolute margins may have been modified 519 // by margin collapsing and must not be overwritten. 520 if matches!(b.css_margin[0], LengthOrAuto::Percentage(_)) { 521 b.margin.top = resolve_length_against(b.css_margin[0], available_width); 522 } 523 if matches!(b.css_margin[1], LengthOrAuto::Percentage(_)) { 524 b.margin.right = resolve_length_against(b.css_margin[1], available_width); 525 } 526 if matches!(b.css_margin[2], LengthOrAuto::Percentage(_)) { 527 b.margin.bottom = resolve_length_against(b.css_margin[2], available_width); 528 } 529 if matches!(b.css_margin[3], LengthOrAuto::Percentage(_)) { 530 b.margin.left = resolve_length_against(b.css_margin[3], available_width); 531 } 532 533 // Resolve percentage padding against containing block width. 534 if matches!(b.css_padding[0], LengthOrAuto::Percentage(_)) { 535 b.padding.top = resolve_length_against(b.css_padding[0], available_width); 536 } 537 if matches!(b.css_padding[1], LengthOrAuto::Percentage(_)) { 538 b.padding.right = resolve_length_against(b.css_padding[1], available_width); 539 } 540 if matches!(b.css_padding[2], LengthOrAuto::Percentage(_)) { 541 b.padding.bottom = resolve_length_against(b.css_padding[2], available_width); 542 } 543 if matches!(b.css_padding[3], LengthOrAuto::Percentage(_)) { 544 b.padding.left = resolve_length_against(b.css_padding[3], available_width); 545 } 546 547 let content_x = x + b.margin.left + b.border.left + b.padding.left; 548 let content_y = y + b.margin.top + b.border.top + b.padding.top; 549 550 let horizontal_extra = b.border.left + b.border.right + b.padding.left + b.padding.right; 551 552 // Resolve content width: explicit CSS width (adjusted for box-sizing) or auto. 553 let content_width = match b.css_width { 554 LengthOrAuto::Length(w) => match b.box_sizing { 555 BoxSizing::ContentBox => w.max(0.0), 556 BoxSizing::BorderBox => (w - horizontal_extra).max(0.0), 557 }, 558 LengthOrAuto::Percentage(p) => { 559 let resolved = p / 100.0 * available_width; 560 match b.box_sizing { 561 BoxSizing::ContentBox => resolved.max(0.0), 562 BoxSizing::BorderBox => (resolved - horizontal_extra).max(0.0), 563 } 564 } 565 LengthOrAuto::Auto => { 566 (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0) 567 } 568 }; 569 570 b.rect.x = content_x; 571 b.rect.y = content_y; 572 b.rect.width = content_width; 573 574 // For overflow:scroll, reserve space for vertical scrollbar. 575 if b.overflow == Overflow::Scroll { 576 b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0); 577 } 578 579 // Replaced elements (e.g., <img>) have intrinsic dimensions. 580 if let Some((rw, rh)) = b.replaced_size { 581 b.rect.width = rw.min(b.rect.width); 582 b.rect.height = rh; 583 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 584 apply_relative_offset(b, available_width, viewport_height); 585 return; 586 } 587 588 match &b.box_type { 589 BoxType::Block(_) | BoxType::Anonymous => { 590 if matches!(b.display, Display::Flex | Display::InlineFlex) { 591 layout_flex_children(b, viewport_width, viewport_height, font, doc, abs_cb); 592 } else if has_block_children(b) { 593 layout_block_children(b, viewport_width, viewport_height, font, doc, abs_cb); 594 } else { 595 layout_inline_children(b, font, doc); 596 } 597 } 598 BoxType::TextRun { .. } | BoxType::Inline(_) => { 599 // Handled by the parent's inline layout. 600 } 601 } 602 603 // Save the natural content height before CSS height override. 604 b.content_height = b.rect.height; 605 606 // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. 607 match b.css_height { 608 LengthOrAuto::Length(h) => { 609 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 610 b.rect.height = match b.box_sizing { 611 BoxSizing::ContentBox => h.max(0.0), 612 BoxSizing::BorderBox => (h - vertical_extra).max(0.0), 613 }; 614 } 615 LengthOrAuto::Percentage(p) => { 616 // Height percentage resolves against containing block height. 617 // For the root element, use viewport height. 618 let cb_height = viewport_height; 619 let resolved = p / 100.0 * cb_height; 620 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 621 b.rect.height = match b.box_sizing { 622 BoxSizing::ContentBox => resolved.max(0.0), 623 BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0), 624 }; 625 } 626 LengthOrAuto::Auto => {} 627 } 628 629 // Layout absolutely and fixed positioned children after this box's 630 // dimensions are fully resolved. 631 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 632 633 apply_relative_offset(b, available_width, viewport_height); 634} 635 636/// Apply `position: relative` offset to a box and all its descendants. 637/// 638/// Resolves the CSS position offsets (which may contain percentages) and 639/// shifts the visual position without affecting the normal-flow layout. 640fn apply_relative_offset(b: &mut LayoutBox, cb_width: f32, cb_height: f32) { 641 if b.position != Position::Relative { 642 return; 643 } 644 let [top, right, bottom, left] = b.css_offsets; 645 let dx = resolve_relative_horizontal(left, right, cb_width); 646 let dy = resolve_relative_vertical(top, bottom, cb_height); 647 b.relative_offset = (dx, dy); 648 if dx == 0.0 && dy == 0.0 { 649 return; 650 } 651 shift_box(b, dx, dy); 652} 653 654/// Recursively shift a box and all its descendants by (dx, dy). 655fn shift_box(b: &mut LayoutBox, dx: f32, dy: f32) { 656 b.rect.x += dx; 657 b.rect.y += dy; 658 for line in &mut b.lines { 659 line.x += dx; 660 line.y += dy; 661 } 662 for child in &mut b.children { 663 shift_box(child, dx, dy); 664 } 665} 666 667// --------------------------------------------------------------------------- 668// Absolute / fixed positioning 669// --------------------------------------------------------------------------- 670 671/// Resolve a `LengthOrAuto` offset to an optional pixel value. 672/// Returns `None` for `Auto` (offset not specified). 673fn resolve_offset(value: LengthOrAuto, reference: f32) -> Option<f32> { 674 match value { 675 LengthOrAuto::Length(px) => Some(px), 676 LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 677 LengthOrAuto::Auto => None, 678 } 679} 680 681/// Compute the padding box rectangle for a layout box. 682fn padding_box_rect(b: &LayoutBox) -> Rect { 683 Rect { 684 x: b.rect.x - b.padding.left, 685 y: b.rect.y - b.padding.top, 686 width: b.rect.width + b.padding.left + b.padding.right, 687 height: b.rect.height + b.padding.top + b.padding.bottom, 688 } 689} 690 691/// Lay out all absolutely and fixed positioned children of `parent`. 692/// 693/// `abs_cb` is the padding box of the nearest positioned ancestor passed from 694/// further up the tree. If `parent` itself is positioned (not `static`), its 695/// padding box becomes the new containing block for absolute descendants. 696fn layout_abspos_children( 697 parent: &mut LayoutBox, 698 abs_cb: Rect, 699 viewport_width: f32, 700 viewport_height: f32, 701 font: &Font, 702 doc: &Document, 703) { 704 let viewport_cb = Rect { 705 x: 0.0, 706 y: 0.0, 707 width: viewport_width, 708 height: viewport_height, 709 }; 710 711 // If this box is positioned, it becomes the containing block for absolute 712 // descendants. 713 let new_abs_cb = if parent.position != Position::Static { 714 padding_box_rect(parent) 715 } else { 716 abs_cb 717 }; 718 719 for i in 0..parent.children.len() { 720 if parent.children[i].position == Position::Absolute { 721 layout_absolute_child( 722 &mut parent.children[i], 723 new_abs_cb, 724 viewport_width, 725 viewport_height, 726 font, 727 doc, 728 ); 729 } else if parent.children[i].position == Position::Fixed { 730 layout_absolute_child( 731 &mut parent.children[i], 732 viewport_cb, 733 viewport_width, 734 viewport_height, 735 font, 736 doc, 737 ); 738 } 739 } 740} 741 742/// Lay out a single absolutely or fixed positioned element. 743/// 744/// `cb` is the containing block (padding box of nearest positioned ancestor 745/// for absolute, or the viewport for fixed). 746fn layout_absolute_child( 747 child: &mut LayoutBox, 748 cb: Rect, 749 viewport_width: f32, 750 viewport_height: f32, 751 font: &Font, 752 doc: &Document, 753) { 754 let [css_top, css_right, css_bottom, css_left] = child.css_offsets; 755 756 // Resolve margins against containing block width. 757 child.margin = EdgeSizes { 758 top: resolve_length_against(child.css_margin[0], cb.width), 759 right: resolve_length_against(child.css_margin[1], cb.width), 760 bottom: resolve_length_against(child.css_margin[2], cb.width), 761 left: resolve_length_against(child.css_margin[3], cb.width), 762 }; 763 764 // Resolve padding against containing block width. 765 child.padding = EdgeSizes { 766 top: resolve_length_against(child.css_padding[0], cb.width), 767 right: resolve_length_against(child.css_padding[1], cb.width), 768 bottom: resolve_length_against(child.css_padding[2], cb.width), 769 left: resolve_length_against(child.css_padding[3], cb.width), 770 }; 771 772 let horiz_extra = 773 child.border.left + child.border.right + child.padding.left + child.padding.right; 774 let vert_extra = 775 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom; 776 777 // Resolve offsets. 778 let left_offset = resolve_offset(css_left, cb.width); 779 let right_offset = resolve_offset(css_right, cb.width); 780 let top_offset = resolve_offset(css_top, cb.height); 781 let bottom_offset = resolve_offset(css_bottom, cb.height); 782 783 // --- Resolve content width --- 784 let content_width = match child.css_width { 785 LengthOrAuto::Length(w) => match child.box_sizing { 786 BoxSizing::ContentBox => w.max(0.0), 787 BoxSizing::BorderBox => (w - horiz_extra).max(0.0), 788 }, 789 LengthOrAuto::Percentage(p) => { 790 let resolved = p / 100.0 * cb.width; 791 match child.box_sizing { 792 BoxSizing::ContentBox => resolved.max(0.0), 793 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 794 } 795 } 796 LengthOrAuto::Auto => { 797 // If both left and right are specified, stretch to fill. 798 if let (Some(l), Some(r)) = (left_offset, right_offset) { 799 (cb.width - l - r - child.margin.left - child.margin.right - horiz_extra).max(0.0) 800 } else { 801 // Use available width minus margins. 802 (cb.width - child.margin.left - child.margin.right - horiz_extra).max(0.0) 803 } 804 } 805 }; 806 807 child.rect.width = content_width; 808 809 // --- Resolve horizontal position --- 810 child.rect.x = if let Some(l) = left_offset { 811 cb.x + l + child.margin.left + child.border.left + child.padding.left 812 } else if let Some(r) = right_offset { 813 cb.x + cb.width 814 - r 815 - child.margin.right 816 - child.border.right 817 - child.padding.right 818 - content_width 819 } else { 820 // Static position: containing block origin. 821 cb.x + child.margin.left + child.border.left + child.padding.left 822 }; 823 824 // Set a temporary y for child content layout. 825 child.rect.y = cb.y; 826 827 // For overflow:scroll, reserve scrollbar width. 828 if child.overflow == Overflow::Scroll { 829 child.rect.width = (child.rect.width - SCROLLBAR_WIDTH).max(0.0); 830 } 831 832 // --- Layout child's own content --- 833 if let Some((rw, rh)) = child.replaced_size { 834 child.rect.width = rw.min(child.rect.width); 835 child.rect.height = rh; 836 } else { 837 let child_abs_cb = if child.position != Position::Static { 838 padding_box_rect(child) 839 } else { 840 cb 841 }; 842 match &child.box_type { 843 BoxType::Block(_) | BoxType::Anonymous => { 844 if matches!(child.display, Display::Flex | Display::InlineFlex) { 845 layout_flex_children( 846 child, 847 viewport_width, 848 viewport_height, 849 font, 850 doc, 851 child_abs_cb, 852 ); 853 } else if has_block_children(child) { 854 layout_block_children( 855 child, 856 viewport_width, 857 viewport_height, 858 font, 859 doc, 860 child_abs_cb, 861 ); 862 } else { 863 layout_inline_children(child, font, doc); 864 } 865 } 866 _ => {} 867 } 868 } 869 870 // Save natural content height. 871 child.content_height = child.rect.height; 872 873 // --- Resolve content height --- 874 match child.css_height { 875 LengthOrAuto::Length(h) => { 876 child.rect.height = match child.box_sizing { 877 BoxSizing::ContentBox => h.max(0.0), 878 BoxSizing::BorderBox => (h - vert_extra).max(0.0), 879 }; 880 } 881 LengthOrAuto::Percentage(p) => { 882 let resolved = p / 100.0 * cb.height; 883 child.rect.height = match child.box_sizing { 884 BoxSizing::ContentBox => resolved.max(0.0), 885 BoxSizing::BorderBox => (resolved - vert_extra).max(0.0), 886 }; 887 } 888 LengthOrAuto::Auto => { 889 // If both top and bottom are specified, stretch. 890 if let (Some(t), Some(b_val)) = (top_offset, bottom_offset) { 891 child.rect.height = 892 (cb.height - t - b_val - child.margin.top - child.margin.bottom - vert_extra) 893 .max(0.0); 894 } 895 // Otherwise keep content height. 896 } 897 } 898 899 // --- Resolve vertical position --- 900 let final_y = if let Some(t) = top_offset { 901 cb.y + t + child.margin.top + child.border.top + child.padding.top 902 } else if let Some(b_val) = bottom_offset { 903 cb.y + cb.height 904 - b_val 905 - child.margin.bottom 906 - child.border.bottom 907 - child.padding.bottom 908 - child.rect.height 909 } else { 910 // Static position: containing block origin. 911 cb.y + child.margin.top + child.border.top + child.padding.top 912 }; 913 914 // Shift from temporary y to final y. 915 let dy = final_y - child.rect.y; 916 if dy != 0.0 { 917 shift_box(child, 0.0, dy); 918 } 919 920 // Recursively lay out any absolutely positioned grandchildren. 921 let new_abs_cb = if child.position != Position::Static { 922 padding_box_rect(child) 923 } else { 924 cb 925 }; 926 layout_abspos_children( 927 child, 928 new_abs_cb, 929 viewport_width, 930 viewport_height, 931 font, 932 doc, 933 ); 934} 935 936fn has_block_children(b: &LayoutBox) -> bool { 937 b.children 938 .iter() 939 .any(|c| is_in_flow(c) && is_block_level(c)) 940} 941 942/// Collapse two adjoining margins per CSS2 §8.3.1. 943/// 944/// Both non-negative → use the larger. 945/// Both negative → use the more negative. 946/// Mixed → sum the largest positive and most negative. 947fn collapse_margins(a: f32, b: f32) -> f32 { 948 if a >= 0.0 && b >= 0.0 { 949 a.max(b) 950 } else if a < 0.0 && b < 0.0 { 951 a.min(b) 952 } else { 953 a + b 954 } 955} 956 957/// Returns `true` if this box establishes a new block formatting context, 958/// which prevents its margins from collapsing with children. 959fn establishes_bfc(b: &LayoutBox) -> bool { 960 b.overflow != Overflow::Visible || matches!(b.display, Display::Flex | Display::InlineFlex) 961} 962 963/// Returns `true` if a block box has no in-flow content (empty block). 964fn is_empty_block(b: &LayoutBox) -> bool { 965 b.children.is_empty() 966 && b.lines.is_empty() 967 && b.replaced_size.is_none() 968 && matches!(b.css_height, LengthOrAuto::Auto) 969} 970 971/// Pre-collapse parent-child margins (CSS2 §8.3.1). 972/// 973/// When a parent has no border/padding/BFC separating it from its first/last 974/// child, the child's margin collapses into the parent's margin. This must 975/// happen *before* positioning so the parent is placed using the collapsed 976/// value. The function walks bottom-up: children are pre-collapsed first, then 977/// their (possibly enlarged) margins are folded into the parent. 978fn pre_collapse_margins(b: &mut LayoutBox) { 979 // Recurse into in-flow block children first (bottom-up). 980 for child in &mut b.children { 981 if is_in_flow(child) && is_block_level(child) { 982 pre_collapse_margins(child); 983 } 984 } 985 986 if !matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) { 987 return; 988 } 989 if establishes_bfc(b) { 990 return; 991 } 992 if !has_block_children(b) { 993 return; 994 } 995 996 // --- Top: collapse with first non-empty child --- 997 if b.border.top == 0.0 && b.padding.top == 0.0 { 998 if let Some(child_top) = first_block_top_margin(&b.children) { 999 b.margin.top = collapse_margins(b.margin.top, child_top); 1000 } 1001 } 1002 1003 // --- Bottom: collapse with last non-empty child --- 1004 if b.border.bottom == 0.0 && b.padding.bottom == 0.0 { 1005 if let Some(child_bottom) = last_block_bottom_margin(&b.children) { 1006 b.margin.bottom = collapse_margins(b.margin.bottom, child_bottom); 1007 } 1008 } 1009} 1010 1011/// Top margin of the first non-empty in-flow block child (already pre-collapsed). 1012fn first_block_top_margin(children: &[LayoutBox]) -> Option<f32> { 1013 for child in children { 1014 if is_in_flow(child) && is_block_level(child) { 1015 if is_empty_block(child) { 1016 continue; 1017 } 1018 return Some(child.margin.top); 1019 } 1020 } 1021 // All in-flow block children empty — fold all their collapsed margins. 1022 let mut m = 0.0f32; 1023 for child in children 1024 .iter() 1025 .filter(|c| is_in_flow(c) && is_block_level(c)) 1026 { 1027 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 1028 } 1029 if m != 0.0 { 1030 Some(m) 1031 } else { 1032 None 1033 } 1034} 1035 1036/// Bottom margin of the last non-empty in-flow block child (already pre-collapsed). 1037fn last_block_bottom_margin(children: &[LayoutBox]) -> Option<f32> { 1038 for child in children.iter().rev() { 1039 if is_in_flow(child) && is_block_level(child) { 1040 if is_empty_block(child) { 1041 continue; 1042 } 1043 return Some(child.margin.bottom); 1044 } 1045 } 1046 let mut m = 0.0f32; 1047 for child in children 1048 .iter() 1049 .filter(|c| is_in_flow(c) && is_block_level(c)) 1050 { 1051 m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 1052 } 1053 if m != 0.0 { 1054 Some(m) 1055 } else { 1056 None 1057 } 1058} 1059 1060/// Lay out block-level children with vertical margin collapsing (CSS2 §8.3.1). 1061/// 1062/// Handles adjacent-sibling collapsing, empty-block collapsing, and 1063/// parent-child internal spacing (the parent's external margins were already 1064/// updated by `pre_collapse_margins`). 1065fn layout_block_children( 1066 parent: &mut LayoutBox, 1067 viewport_width: f32, 1068 viewport_height: f32, 1069 font: &Font, 1070 doc: &Document, 1071 abs_cb: Rect, 1072) { 1073 let content_x = parent.rect.x; 1074 let content_width = parent.rect.width; 1075 let mut cursor_y = parent.rect.y; 1076 1077 let parent_top_open = 1078 parent.border.top == 0.0 && parent.padding.top == 0.0 && !establishes_bfc(parent); 1079 let parent_bottom_open = 1080 parent.border.bottom == 0.0 && parent.padding.bottom == 0.0 && !establishes_bfc(parent); 1081 1082 // Pending bottom margin from the previous sibling. 1083 let mut pending_margin: Option<f32> = None; 1084 let child_count = parent.children.len(); 1085 // Track whether we've seen any in-flow children (for parent_top_open). 1086 let mut first_in_flow = true; 1087 1088 for i in 0..child_count { 1089 // Skip out-of-flow children (absolute/fixed) — they are laid out 1090 // separately in layout_abspos_children. 1091 if !is_in_flow(&parent.children[i]) { 1092 continue; 1093 } 1094 1095 let child_top_margin = parent.children[i].margin.top; 1096 let child_bottom_margin = parent.children[i].margin.bottom; 1097 1098 // --- Empty block: top+bottom margins self-collapse --- 1099 if is_empty_block(&parent.children[i]) { 1100 let self_collapsed = collapse_margins(child_top_margin, child_bottom_margin); 1101 pending_margin = Some(match pending_margin { 1102 Some(prev) => collapse_margins(prev, self_collapsed), 1103 None => self_collapsed, 1104 }); 1105 // Position at cursor_y with zero height. 1106 let child = &mut parent.children[i]; 1107 child.rect.x = content_x + child.border.left + child.padding.left; 1108 child.rect.y = cursor_y + child.border.top + child.padding.top; 1109 child.rect.width = (content_width 1110 - child.border.left 1111 - child.border.right 1112 - child.padding.left 1113 - child.padding.right) 1114 .max(0.0); 1115 child.rect.height = 0.0; 1116 first_in_flow = false; 1117 continue; 1118 } 1119 1120 // --- Compute effective top spacing --- 1121 let collapsed_top = if let Some(prev_bottom) = pending_margin.take() { 1122 // Sibling collapsing: previous bottom vs this top. 1123 collapse_margins(prev_bottom, child_top_margin) 1124 } else if first_in_flow && parent_top_open { 1125 // First child, parent top open: margin was already pulled into 1126 // parent by pre_collapse_margins — no internal spacing. 1127 0.0 1128 } else { 1129 child_top_margin 1130 }; 1131 1132 // `compute_layout` adds `child.margin.top` internally, so compensate. 1133 let y_for_child = cursor_y + collapsed_top - child_top_margin; 1134 compute_layout( 1135 &mut parent.children[i], 1136 content_x, 1137 y_for_child, 1138 content_width, 1139 viewport_width, 1140 viewport_height, 1141 font, 1142 doc, 1143 abs_cb, 1144 ); 1145 1146 let child = &parent.children[i]; 1147 // Use the normal-flow position (before relative offset) so that 1148 // `position: relative` does not affect sibling placement. 1149 let (_, rel_dy) = child.relative_offset; 1150 cursor_y = (child.rect.y - rel_dy) 1151 + child.rect.height 1152 + child.padding.bottom 1153 + child.border.bottom; 1154 pending_margin = Some(child_bottom_margin); 1155 first_in_flow = false; 1156 } 1157 1158 // Trailing margin. 1159 if let Some(trailing) = pending_margin { 1160 if !parent_bottom_open { 1161 // Parent has border/padding at bottom — margin stays inside. 1162 cursor_y += trailing; 1163 } 1164 // If parent_bottom_open, the margin was already pulled into the 1165 // parent by pre_collapse_margins. 1166 } 1167 1168 parent.rect.height = cursor_y - parent.rect.y; 1169} 1170 1171// --------------------------------------------------------------------------- 1172// Flex layout 1173// --------------------------------------------------------------------------- 1174 1175/// Measure the intrinsic max-content width of a flex item's content. 1176/// Returns the width needed to lay out content without wrapping. 1177fn measure_flex_item_max_content_width(child: &LayoutBox, font: &Font, _doc: &Document) -> f32 { 1178 // Measure the widest text line from all inline content. 1179 let mut max_width = 0.0f32; 1180 measure_box_content_width(child, font, &mut max_width); 1181 max_width 1182} 1183 1184/// Recursively measure max content width of a layout box tree. 1185fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { 1186 match &b.box_type { 1187 BoxType::TextRun { text, .. } => { 1188 let w = measure_text_width(font, text, b.font_size); 1189 if w > *max_w { 1190 *max_w = w; 1191 } 1192 } 1193 _ => { 1194 let horiz = b.border.left + b.border.right + b.padding.left + b.padding.right; 1195 let mut child_max = 0.0f32; 1196 for child in &b.children { 1197 measure_box_content_width(child, font, &mut child_max); 1198 } 1199 let total = child_max + horiz; 1200 if total > *max_w { 1201 *max_w = total; 1202 } 1203 } 1204 } 1205} 1206 1207/// Lay out children according to the CSS Flexbox algorithm (Level 1 §9). 1208fn layout_flex_children( 1209 parent: &mut LayoutBox, 1210 viewport_width: f32, 1211 viewport_height: f32, 1212 font: &Font, 1213 doc: &Document, 1214 abs_cb: Rect, 1215) { 1216 let flex_direction = parent.flex_direction; 1217 let flex_wrap = parent.flex_wrap; 1218 let justify_content = parent.justify_content; 1219 let align_items = parent.align_items; 1220 let align_content = parent.align_content; 1221 let container_main_size = match flex_direction { 1222 FlexDirection::Row | FlexDirection::RowReverse => parent.rect.width, 1223 FlexDirection::Column | FlexDirection::ColumnReverse => { 1224 match parent.css_height { 1225 LengthOrAuto::Length(h) => h, 1226 LengthOrAuto::Percentage(p) => p / 100.0 * viewport_height, 1227 LengthOrAuto::Auto => viewport_height, // fallback 1228 } 1229 } 1230 }; 1231 let container_cross_size = match flex_direction { 1232 FlexDirection::Row | FlexDirection::RowReverse => match parent.css_height { 1233 LengthOrAuto::Length(h) => Some(h), 1234 LengthOrAuto::Percentage(p) => Some(p / 100.0 * viewport_height), 1235 LengthOrAuto::Auto => None, 1236 }, 1237 FlexDirection::Column | FlexDirection::ColumnReverse => Some(parent.rect.width), 1238 }; 1239 1240 let is_row = matches!( 1241 flex_direction, 1242 FlexDirection::Row | FlexDirection::RowReverse 1243 ); 1244 let is_reverse = matches!( 1245 flex_direction, 1246 FlexDirection::RowReverse | FlexDirection::ColumnReverse 1247 ); 1248 1249 // Per CSS Box Alignment §8, row-gap applies between rows and column-gap 1250 // between columns. In a row flex container the main axis is horizontal 1251 // (column-gap between items, row-gap between lines). In a column flex 1252 // container the axes are swapped. 1253 let (main_gap, cross_gap) = if is_row { 1254 (parent.column_gap, parent.row_gap) 1255 } else { 1256 (parent.row_gap, parent.column_gap) 1257 }; 1258 1259 if parent.children.is_empty() { 1260 parent.rect.height = 0.0; 1261 return; 1262 } 1263 1264 // Sort children by `order` (stable sort preserves DOM order for equal values). 1265 let child_count = parent.children.len(); 1266 let mut order_indices: Vec<usize> = (0..child_count).collect(); 1267 order_indices.sort_by_key(|&i| parent.children[i].order); 1268 1269 // Step 1: Determine flex base sizes and hypothetical main sizes. 1270 struct FlexItemInfo { 1271 index: usize, 1272 base_size: f32, 1273 hypothetical_main: f32, 1274 flex_grow: f32, 1275 flex_shrink: f32, 1276 frozen: bool, 1277 target_main: f32, 1278 outer_main: f32, // margin+border+padding on main axis 1279 outer_cross: f32, // margin+border+padding on cross axis 1280 } 1281 1282 let mut items: Vec<FlexItemInfo> = Vec::with_capacity(child_count); 1283 1284 for &idx in &order_indices { 1285 // Skip out-of-flow children (absolute/fixed). 1286 if !is_in_flow(&parent.children[idx]) { 1287 continue; 1288 } 1289 let child = &mut parent.children[idx]; 1290 1291 // Resolve margin/padding percentages for the child. 1292 let cb_width = if is_row { 1293 container_main_size 1294 } else { 1295 container_cross_size.unwrap_or(parent.rect.width) 1296 }; 1297 1298 // Resolve percentage margins and padding against containing block width. 1299 for i in 0..4 { 1300 if matches!(child.css_margin[i], LengthOrAuto::Percentage(_)) { 1301 let resolved = resolve_length_against(child.css_margin[i], cb_width); 1302 match i { 1303 0 => child.margin.top = resolved, 1304 1 => child.margin.right = resolved, 1305 2 => child.margin.bottom = resolved, 1306 3 => child.margin.left = resolved, 1307 _ => {} 1308 } 1309 } 1310 if matches!(child.css_padding[i], LengthOrAuto::Percentage(_)) { 1311 let resolved = resolve_length_against(child.css_padding[i], cb_width); 1312 match i { 1313 0 => child.padding.top = resolved, 1314 1 => child.padding.right = resolved, 1315 2 => child.padding.bottom = resolved, 1316 3 => child.padding.left = resolved, 1317 _ => {} 1318 } 1319 } 1320 } 1321 1322 let margin_main = if is_row { 1323 child.margin.left + child.margin.right 1324 } else { 1325 child.margin.top + child.margin.bottom 1326 }; 1327 let border_padding_main = if is_row { 1328 child.border.left + child.border.right + child.padding.left + child.padding.right 1329 } else { 1330 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom 1331 }; 1332 let margin_cross = if is_row { 1333 child.margin.top + child.margin.bottom 1334 } else { 1335 child.margin.left + child.margin.right 1336 }; 1337 let border_padding_cross = if is_row { 1338 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom 1339 } else { 1340 child.border.left + child.border.right + child.padding.left + child.padding.right 1341 }; 1342 1343 // Determine flex-basis. 1344 let flex_basis = child.flex_basis; 1345 let specified_main = if is_row { 1346 child.css_width 1347 } else { 1348 child.css_height 1349 }; 1350 1351 let base_size = match flex_basis { 1352 LengthOrAuto::Length(px) => px, 1353 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1354 LengthOrAuto::Auto => { 1355 // Use specified main size if set, otherwise content size. 1356 match specified_main { 1357 LengthOrAuto::Length(px) => px, 1358 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1359 LengthOrAuto::Auto => { 1360 // Content-based sizing: use max-content size. 1361 if is_row { 1362 measure_flex_item_max_content_width(child, font, doc) 1363 } else { 1364 // For column direction, measure content height. 1365 let avail = container_cross_size.unwrap_or(parent.rect.width); 1366 compute_layout( 1367 child, 1368 0.0, 1369 0.0, 1370 avail, 1371 viewport_width, 1372 viewport_height, 1373 font, 1374 doc, 1375 abs_cb, 1376 ); 1377 child.rect.height 1378 } 1379 } 1380 } 1381 } 1382 }; 1383 1384 let hypothetical_main = base_size.max(0.0); 1385 let outer = margin_main + border_padding_main; 1386 1387 items.push(FlexItemInfo { 1388 index: idx, 1389 base_size, 1390 hypothetical_main, 1391 flex_grow: child.flex_grow, 1392 flex_shrink: child.flex_shrink, 1393 frozen: false, 1394 target_main: hypothetical_main, 1395 outer_main: outer, 1396 outer_cross: margin_cross + border_padding_cross, 1397 }); 1398 } 1399 1400 // Step 2: Collect items into flex lines. 1401 let mut lines: Vec<Vec<usize>> = Vec::new(); // indices into `items` 1402 1403 if flex_wrap == FlexWrap::Nowrap { 1404 // All items on one line. 1405 lines.push((0..items.len()).collect()); 1406 } else { 1407 let mut current_line: Vec<usize> = Vec::new(); 1408 let mut line_main_size = 0.0f32; 1409 1410 for (i, item) in items.iter().enumerate() { 1411 let item_outer = item.hypothetical_main + item.outer_main; 1412 let gap = if current_line.is_empty() { 1413 0.0 1414 } else { 1415 main_gap 1416 }; 1417 1418 if !current_line.is_empty() && line_main_size + gap + item_outer > container_main_size { 1419 lines.push(std::mem::take(&mut current_line)); 1420 line_main_size = 0.0; 1421 } 1422 1423 if !current_line.is_empty() { 1424 line_main_size += main_gap; 1425 } 1426 line_main_size += item_outer; 1427 current_line.push(i); 1428 } 1429 1430 if !current_line.is_empty() { 1431 lines.push(current_line); 1432 } 1433 } 1434 1435 if flex_wrap == FlexWrap::WrapReverse { 1436 lines.reverse(); 1437 } 1438 1439 // Step 3: Resolve flexible lengths for each line. 1440 for line in &lines { 1441 // Total hypothetical main sizes + gaps. 1442 let total_gaps = if line.len() > 1 { 1443 (line.len() - 1) as f32 * main_gap 1444 } else { 1445 0.0 1446 }; 1447 let total_outer_hypo: f32 = line 1448 .iter() 1449 .map(|&i| items[i].hypothetical_main + items[i].outer_main) 1450 .sum(); 1451 let used_space = total_outer_hypo + total_gaps; 1452 let free_space = container_main_size - used_space; 1453 1454 // Reset frozen state. 1455 for &i in line { 1456 items[i].frozen = false; 1457 items[i].target_main = items[i].hypothetical_main; 1458 } 1459 1460 if free_space > 0.0 { 1461 // Grow items. 1462 let total_grow: f32 = line.iter().map(|&i| items[i].flex_grow).sum(); 1463 if total_grow > 0.0 { 1464 for &i in line { 1465 if items[i].flex_grow > 0.0 { 1466 items[i].target_main += free_space * (items[i].flex_grow / total_grow); 1467 } 1468 } 1469 } 1470 } else if free_space < 0.0 { 1471 // Shrink items. 1472 let total_shrink_scaled: f32 = line 1473 .iter() 1474 .map(|&i| items[i].flex_shrink * items[i].base_size) 1475 .sum(); 1476 if total_shrink_scaled > 0.0 { 1477 for &i in line { 1478 let scaled = items[i].flex_shrink * items[i].base_size; 1479 let ratio = scaled / total_shrink_scaled; 1480 items[i].target_main = 1481 (items[i].hypothetical_main + free_space * ratio).max(0.0); 1482 } 1483 } 1484 } 1485 } 1486 1487 // Step 4: Determine cross sizes of items by laying them out at their target main size. 1488 struct LineCrossInfo { 1489 max_cross: f32, 1490 } 1491 1492 let mut line_infos: Vec<LineCrossInfo> = Vec::new(); 1493 1494 for line in &lines { 1495 let mut max_cross = 0.0f32; 1496 1497 for &i in line { 1498 let idx = items[i].index; 1499 let child = &mut parent.children[idx]; 1500 let target_main = items[i].target_main; 1501 1502 // Set up the child for layout at the resolved main size. 1503 if is_row { 1504 child.css_width = LengthOrAuto::Length(target_main); 1505 compute_layout( 1506 child, 1507 0.0, 1508 0.0, 1509 target_main, 1510 viewport_width, 1511 viewport_height, 1512 font, 1513 doc, 1514 abs_cb, 1515 ); 1516 let cross = child.rect.height + items[i].outer_cross; 1517 if cross > max_cross { 1518 max_cross = cross; 1519 } 1520 } else { 1521 let avail = container_cross_size.unwrap_or(parent.rect.width); 1522 compute_layout( 1523 child, 1524 0.0, 1525 0.0, 1526 avail, 1527 viewport_width, 1528 viewport_height, 1529 font, 1530 doc, 1531 abs_cb, 1532 ); 1533 child.rect.height = target_main; 1534 let cross = child.rect.width 1535 + child.border.left 1536 + child.border.right 1537 + child.padding.left 1538 + child.padding.right 1539 + child.margin.left 1540 + child.margin.right; 1541 if cross > max_cross { 1542 max_cross = cross; 1543 } 1544 } 1545 } 1546 1547 line_infos.push(LineCrossInfo { max_cross }); 1548 } 1549 1550 // Per CSS Flexbox §9.4: If the flex container is single-line and has a 1551 // definite cross size, the cross size of the flex line is the container's 1552 // cross size (clamped to min/max). This ensures alignment works relative 1553 // to the full container. 1554 if lines.len() == 1 { 1555 if let Some(cs) = container_cross_size { 1556 if line_infos[0].max_cross < cs { 1557 line_infos[0].max_cross = cs; 1558 } 1559 } 1560 } 1561 1562 // Step 5: Handle align-items: stretch — stretch items to fill the line cross size. 1563 for (line_idx, line) in lines.iter().enumerate() { 1564 let line_cross = line_infos[line_idx].max_cross; 1565 1566 for &i in line { 1567 let idx = items[i].index; 1568 let child = &mut parent.children[idx]; 1569 1570 let effective_align = match child.align_self { 1571 AlignSelf::Auto => align_items, 1572 AlignSelf::FlexStart => AlignItems::FlexStart, 1573 AlignSelf::FlexEnd => AlignItems::FlexEnd, 1574 AlignSelf::Center => AlignItems::Center, 1575 AlignSelf::Baseline => AlignItems::Baseline, 1576 AlignSelf::Stretch => AlignItems::Stretch, 1577 }; 1578 1579 if effective_align == AlignItems::Stretch { 1580 let item_cross_space = line_cross - items[i].outer_cross; 1581 if is_row { 1582 if matches!(child.css_height, LengthOrAuto::Auto) { 1583 child.rect.height = item_cross_space.max(0.0); 1584 } 1585 } else if matches!(child.css_width, LengthOrAuto::Auto) { 1586 child.rect.width = item_cross_space.max(0.0); 1587 } 1588 } 1589 } 1590 } 1591 1592 // Step 6: Position items on main and cross axes. 1593 let total_cross_gaps = if lines.len() > 1 { 1594 (lines.len() - 1) as f32 * cross_gap 1595 } else { 1596 0.0 1597 }; 1598 let total_line_cross: f32 = line_infos.iter().map(|li| li.max_cross).sum(); 1599 let total_cross_used = total_line_cross + total_cross_gaps; 1600 1601 // For definite cross size containers, compute align-content offsets. 1602 let cross_free_space = container_cross_size 1603 .map(|cs| (cs - total_cross_used).max(0.0)) 1604 .unwrap_or(0.0); 1605 1606 let (ac_initial_offset, ac_between_offset) = compute_content_distribution( 1607 align_content_to_justify(align_content), 1608 cross_free_space, 1609 lines.len(), 1610 ); 1611 1612 let mut cross_cursor = if is_row { parent.rect.y } else { parent.rect.x } + ac_initial_offset; 1613 1614 for (line_idx, line) in lines.iter().enumerate() { 1615 let line_cross = line_infos[line_idx].max_cross; 1616 1617 // Main-axis justification. 1618 let total_main_gaps = if line.len() > 1 { 1619 (line.len() - 1) as f32 * main_gap 1620 } else { 1621 0.0 1622 }; 1623 let items_main: f32 = line 1624 .iter() 1625 .map(|&i| items[i].target_main + items[i].outer_main) 1626 .sum(); 1627 let main_free = (container_main_size - items_main - total_main_gaps).max(0.0); 1628 1629 let (jc_initial, jc_between) = 1630 compute_content_distribution(justify_content, main_free, line.len()); 1631 1632 // Determine the starting main position. 1633 let mut main_cursor = if is_row { parent.rect.x } else { parent.rect.y }; 1634 1635 if is_reverse { 1636 // For reverse directions, start from the end. 1637 main_cursor += container_main_size; 1638 } 1639 1640 main_cursor += if is_reverse { -jc_initial } else { jc_initial }; 1641 1642 let line_items: Vec<usize> = if is_reverse { 1643 line.iter().rev().copied().collect() 1644 } else { 1645 line.to_vec() 1646 }; 1647 1648 for (item_pos, &i) in line_items.iter().enumerate() { 1649 let idx = items[i].index; 1650 let child = &mut parent.children[idx]; 1651 let target_main = items[i].target_main; 1652 1653 // Main-axis position. 1654 let child_main_margin_start = if is_row { 1655 child.margin.left 1656 } else { 1657 child.margin.top 1658 }; 1659 let child_main_margin_end = if is_row { 1660 child.margin.right 1661 } else { 1662 child.margin.bottom 1663 }; 1664 let child_main_bp_start = if is_row { 1665 child.border.left + child.padding.left 1666 } else { 1667 child.border.top + child.padding.top 1668 }; 1669 let child_main_bp_end = if is_row { 1670 child.border.right + child.padding.right 1671 } else { 1672 child.border.bottom + child.padding.bottom 1673 }; 1674 1675 let outer_size = child_main_margin_start 1676 + child_main_bp_start 1677 + target_main 1678 + child_main_bp_end 1679 + child_main_margin_end; 1680 1681 if is_reverse { 1682 main_cursor -= outer_size; 1683 } 1684 1685 let content_main = main_cursor + child_main_margin_start + child_main_bp_start; 1686 1687 // Cross-axis position. 1688 let child_cross_margin_start = if is_row { 1689 child.margin.top 1690 } else { 1691 child.margin.left 1692 }; 1693 let child_cross_bp_start = if is_row { 1694 child.border.top + child.padding.top 1695 } else { 1696 child.border.left + child.padding.left 1697 }; 1698 let child_cross_total = if is_row { 1699 child.rect.height + items[i].outer_cross 1700 } else { 1701 child.rect.width + items[i].outer_cross 1702 }; 1703 1704 let effective_align = match child.align_self { 1705 AlignSelf::Auto => align_items, 1706 AlignSelf::FlexStart => AlignItems::FlexStart, 1707 AlignSelf::FlexEnd => AlignItems::FlexEnd, 1708 AlignSelf::Center => AlignItems::Center, 1709 AlignSelf::Baseline => AlignItems::Baseline, 1710 AlignSelf::Stretch => AlignItems::Stretch, 1711 }; 1712 1713 let cross_offset = match effective_align { 1714 AlignItems::FlexStart | AlignItems::Stretch | AlignItems::Baseline => 0.0, 1715 AlignItems::FlexEnd => line_cross - child_cross_total, 1716 AlignItems::Center => (line_cross - child_cross_total) / 2.0, 1717 }; 1718 1719 let content_cross = 1720 cross_cursor + cross_offset + child_cross_margin_start + child_cross_bp_start; 1721 1722 // Set child position. 1723 if is_row { 1724 child.rect.x = content_main; 1725 child.rect.y = content_cross; 1726 child.rect.width = target_main; 1727 } else { 1728 child.rect.x = content_cross; 1729 child.rect.y = content_main; 1730 child.rect.height = target_main; 1731 } 1732 1733 // Shift any text lines to match new position. 1734 reposition_lines(child, font, doc); 1735 1736 if !is_reverse { 1737 main_cursor += outer_size; 1738 } 1739 1740 // Add gap between items (not after last). 1741 if item_pos < line_items.len() - 1 { 1742 if is_reverse { 1743 main_cursor -= main_gap; 1744 } else { 1745 main_cursor += main_gap; 1746 } 1747 } 1748 1749 // Also add justify-content between spacing. 1750 if item_pos < line_items.len() - 1 { 1751 if is_reverse { 1752 main_cursor -= jc_between; 1753 } else { 1754 main_cursor += jc_between; 1755 } 1756 } 1757 } 1758 1759 cross_cursor += line_cross + cross_gap + ac_between_offset; 1760 } 1761 1762 // Set parent height based on children. 1763 if is_row { 1764 if matches!(parent.css_height, LengthOrAuto::Auto) { 1765 parent.rect.height = total_cross_used; 1766 } 1767 } else if matches!(parent.css_height, LengthOrAuto::Auto) { 1768 // For column flex, height is the main axis. 1769 let total_main: f32 = lines 1770 .iter() 1771 .map(|line| { 1772 let items_main: f32 = line 1773 .iter() 1774 .map(|&i| items[i].target_main + items[i].outer_main) 1775 .sum(); 1776 let gaps = if line.len() > 1 { 1777 (line.len() - 1) as f32 * main_gap 1778 } else { 1779 0.0 1780 }; 1781 items_main + gaps 1782 }) 1783 .fold(0.0f32, f32::max); 1784 parent.rect.height = total_main; 1785 } 1786} 1787 1788/// Reposition inline text lines inside a layout box after moving it. 1789/// Re-runs inline layout if the box has inline children. 1790fn reposition_lines(b: &mut LayoutBox, font: &Font, doc: &Document) { 1791 if !b.lines.is_empty() { 1792 // Re-run inline layout at the new position. 1793 b.lines.clear(); 1794 layout_inline_children(b, font, doc); 1795 } 1796 // Recursively reposition children that have their own inline content. 1797 for child in &mut b.children { 1798 if !child.lines.is_empty() { 1799 child.lines.clear(); 1800 layout_inline_children(child, font, doc); 1801 } 1802 } 1803} 1804 1805/// Convert AlignContent to JustifyContent for reuse of distribution logic. 1806fn align_content_to_justify(ac: AlignContent) -> JustifyContent { 1807 match ac { 1808 AlignContent::FlexStart | AlignContent::Stretch => JustifyContent::FlexStart, 1809 AlignContent::FlexEnd => JustifyContent::FlexEnd, 1810 AlignContent::Center => JustifyContent::Center, 1811 AlignContent::SpaceBetween => JustifyContent::SpaceBetween, 1812 AlignContent::SpaceAround => JustifyContent::SpaceAround, 1813 } 1814} 1815 1816/// Compute the initial offset and between-item spacing for content distribution. 1817/// Returns (initial_offset, between_spacing). 1818fn compute_content_distribution( 1819 mode: JustifyContent, 1820 free_space: f32, 1821 item_count: usize, 1822) -> (f32, f32) { 1823 if item_count == 0 { 1824 return (0.0, 0.0); 1825 } 1826 match mode { 1827 JustifyContent::FlexStart => (0.0, 0.0), 1828 JustifyContent::FlexEnd => (free_space, 0.0), 1829 JustifyContent::Center => (free_space / 2.0, 0.0), 1830 JustifyContent::SpaceBetween => { 1831 if item_count <= 1 { 1832 (0.0, 0.0) 1833 } else { 1834 (0.0, free_space / (item_count - 1) as f32) 1835 } 1836 } 1837 JustifyContent::SpaceAround => { 1838 let per_item = free_space / item_count as f32; 1839 (per_item / 2.0, per_item) 1840 } 1841 JustifyContent::SpaceEvenly => { 1842 let spacing = free_space / (item_count + 1) as f32; 1843 (spacing, spacing) 1844 } 1845 } 1846} 1847 1848// --------------------------------------------------------------------------- 1849// Inline formatting context 1850// --------------------------------------------------------------------------- 1851 1852/// An inline item produced by flattening the inline tree. 1853enum InlineItemKind { 1854 /// A word of text with associated styling. 1855 Word { 1856 text: String, 1857 font_size: f32, 1858 color: Color, 1859 text_decoration: TextDecoration, 1860 background_color: Color, 1861 }, 1862 /// Whitespace between words. 1863 Space { font_size: f32 }, 1864 /// Forced line break (`<br>`). 1865 ForcedBreak, 1866 /// Start of an inline box (for margin/padding/border tracking). 1867 InlineStart { 1868 margin_left: f32, 1869 padding_left: f32, 1870 border_left: f32, 1871 }, 1872 /// End of an inline box. 1873 InlineEnd { 1874 margin_right: f32, 1875 padding_right: f32, 1876 border_right: f32, 1877 }, 1878} 1879 1880/// A pending fragment on the current line. 1881struct PendingFragment { 1882 text: String, 1883 x: f32, 1884 width: f32, 1885 font_size: f32, 1886 color: Color, 1887 text_decoration: TextDecoration, 1888 background_color: Color, 1889} 1890 1891/// Flatten the inline children tree into a sequence of items. 1892fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec<InlineItemKind>) { 1893 for child in children { 1894 // Skip out-of-flow children (absolute/fixed positioned). 1895 if !is_in_flow(child) { 1896 continue; 1897 } 1898 match &child.box_type { 1899 BoxType::TextRun { text, .. } => { 1900 let words = split_into_words(text); 1901 for segment in words { 1902 match segment { 1903 WordSegment::Word(w) => { 1904 items.push(InlineItemKind::Word { 1905 text: w, 1906 font_size: child.font_size, 1907 color: child.color, 1908 text_decoration: child.text_decoration, 1909 background_color: child.background_color, 1910 }); 1911 } 1912 WordSegment::Space => { 1913 items.push(InlineItemKind::Space { 1914 font_size: child.font_size, 1915 }); 1916 } 1917 } 1918 } 1919 } 1920 BoxType::Inline(node_id) => { 1921 if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { 1922 if tag_name == "br" { 1923 items.push(InlineItemKind::ForcedBreak); 1924 continue; 1925 } 1926 } 1927 1928 items.push(InlineItemKind::InlineStart { 1929 margin_left: child.margin.left, 1930 padding_left: child.padding.left, 1931 border_left: child.border.left, 1932 }); 1933 1934 flatten_inline_tree(&child.children, doc, items); 1935 1936 items.push(InlineItemKind::InlineEnd { 1937 margin_right: child.margin.right, 1938 padding_right: child.padding.right, 1939 border_right: child.border.right, 1940 }); 1941 } 1942 _ => {} 1943 } 1944 } 1945} 1946 1947enum WordSegment { 1948 Word(String), 1949 Space, 1950} 1951 1952/// Split text into alternating words and spaces. 1953fn split_into_words(text: &str) -> Vec<WordSegment> { 1954 let mut segments = Vec::new(); 1955 let mut current_word = String::new(); 1956 1957 for ch in text.chars() { 1958 if ch == ' ' { 1959 if !current_word.is_empty() { 1960 segments.push(WordSegment::Word(std::mem::take(&mut current_word))); 1961 } 1962 segments.push(WordSegment::Space); 1963 } else { 1964 current_word.push(ch); 1965 } 1966 } 1967 1968 if !current_word.is_empty() { 1969 segments.push(WordSegment::Word(current_word)); 1970 } 1971 1972 segments 1973} 1974 1975/// Lay out inline children using a proper inline formatting context. 1976fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 1977 let available_width = parent.rect.width; 1978 let text_align = parent.text_align; 1979 let line_height = parent.line_height; 1980 1981 let mut items = Vec::new(); 1982 flatten_inline_tree(&parent.children, doc, &mut items); 1983 1984 if items.is_empty() { 1985 parent.rect.height = 0.0; 1986 return; 1987 } 1988 1989 // Process items into line boxes. 1990 let mut all_lines: Vec<Vec<PendingFragment>> = Vec::new(); 1991 let mut current_line: Vec<PendingFragment> = Vec::new(); 1992 let mut cursor_x: f32 = 0.0; 1993 1994 for item in &items { 1995 match item { 1996 InlineItemKind::Word { 1997 text, 1998 font_size, 1999 color, 2000 text_decoration, 2001 background_color, 2002 } => { 2003 let word_width = measure_text_width(font, text, *font_size); 2004 2005 // If this word doesn't fit and the line isn't empty, break. 2006 if cursor_x > 0.0 && cursor_x + word_width > available_width { 2007 all_lines.push(std::mem::take(&mut current_line)); 2008 cursor_x = 0.0; 2009 } 2010 2011 current_line.push(PendingFragment { 2012 text: text.clone(), 2013 x: cursor_x, 2014 width: word_width, 2015 font_size: *font_size, 2016 color: *color, 2017 text_decoration: *text_decoration, 2018 background_color: *background_color, 2019 }); 2020 cursor_x += word_width; 2021 } 2022 InlineItemKind::Space { font_size } => { 2023 // Only add space if we have content on the line. 2024 if !current_line.is_empty() { 2025 let space_width = measure_text_width(font, " ", *font_size); 2026 if cursor_x + space_width <= available_width { 2027 cursor_x += space_width; 2028 } 2029 } 2030 } 2031 InlineItemKind::ForcedBreak => { 2032 all_lines.push(std::mem::take(&mut current_line)); 2033 cursor_x = 0.0; 2034 } 2035 InlineItemKind::InlineStart { 2036 margin_left, 2037 padding_left, 2038 border_left, 2039 } => { 2040 cursor_x += margin_left + padding_left + border_left; 2041 } 2042 InlineItemKind::InlineEnd { 2043 margin_right, 2044 padding_right, 2045 border_right, 2046 } => { 2047 cursor_x += margin_right + padding_right + border_right; 2048 } 2049 } 2050 } 2051 2052 // Flush the last line. 2053 if !current_line.is_empty() { 2054 all_lines.push(current_line); 2055 } 2056 2057 if all_lines.is_empty() { 2058 parent.rect.height = 0.0; 2059 return; 2060 } 2061 2062 // Position lines vertically and apply text-align. 2063 let mut text_lines = Vec::new(); 2064 let mut y = parent.rect.y; 2065 let num_lines = all_lines.len(); 2066 2067 for (line_idx, line_fragments) in all_lines.iter().enumerate() { 2068 if line_fragments.is_empty() { 2069 y += line_height; 2070 continue; 2071 } 2072 2073 // Compute line width from last fragment. 2074 let line_width = match line_fragments.last() { 2075 Some(last) => last.x + last.width, 2076 None => 0.0, 2077 }; 2078 2079 // Compute text-align offset. 2080 let is_last_line = line_idx == num_lines - 1; 2081 let align_offset = 2082 compute_align_offset(text_align, available_width, line_width, is_last_line); 2083 2084 for frag in line_fragments { 2085 text_lines.push(TextLine { 2086 text: frag.text.clone(), 2087 x: parent.rect.x + frag.x + align_offset, 2088 y, 2089 width: frag.width, 2090 font_size: frag.font_size, 2091 color: frag.color, 2092 text_decoration: frag.text_decoration, 2093 background_color: frag.background_color, 2094 }); 2095 } 2096 2097 y += line_height; 2098 } 2099 2100 parent.rect.height = num_lines as f32 * line_height; 2101 parent.lines = text_lines; 2102} 2103 2104/// Compute the horizontal offset for text alignment. 2105fn compute_align_offset( 2106 align: TextAlign, 2107 available_width: f32, 2108 line_width: f32, 2109 is_last_line: bool, 2110) -> f32 { 2111 let extra_space = (available_width - line_width).max(0.0); 2112 match align { 2113 TextAlign::Left => 0.0, 2114 TextAlign::Center => extra_space / 2.0, 2115 TextAlign::Right => extra_space, 2116 TextAlign::Justify => { 2117 // Don't justify the last line (CSS spec behavior). 2118 if is_last_line { 2119 0.0 2120 } else { 2121 // For justify, we shift the whole line by 0 — the actual distribution 2122 // of space between words would need per-word spacing. For now, treat 2123 // as left-aligned; full justify support is a future enhancement. 2124 0.0 2125 } 2126 } 2127 } 2128} 2129 2130// --------------------------------------------------------------------------- 2131// Text measurement 2132// --------------------------------------------------------------------------- 2133 2134/// Measure the total advance width of a text string at the given font size. 2135fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { 2136 let shaped = font.shape_text(text, font_size); 2137 match shaped.last() { 2138 Some(last) => last.x_offset + last.x_advance, 2139 None => 0.0, 2140 } 2141} 2142 2143// --------------------------------------------------------------------------- 2144// Public API 2145// --------------------------------------------------------------------------- 2146 2147/// Build and lay out from a styled tree (produced by `resolve_styles`). 2148/// 2149/// Returns a `LayoutTree` with positioned boxes ready for rendering. 2150pub fn layout( 2151 styled_root: &StyledNode, 2152 doc: &Document, 2153 viewport_width: f32, 2154 viewport_height: f32, 2155 font: &Font, 2156 image_sizes: &HashMap<NodeId, (f32, f32)>, 2157) -> LayoutTree { 2158 let mut root = match build_box(styled_root, doc, image_sizes) { 2159 Some(b) => b, 2160 None => { 2161 return LayoutTree { 2162 root: LayoutBox::new(BoxType::Anonymous, &ComputedStyle::default()), 2163 width: viewport_width, 2164 height: 0.0, 2165 }; 2166 } 2167 }; 2168 2169 // Pre-collapse parent-child margins before positioning. 2170 pre_collapse_margins(&mut root); 2171 2172 // The initial containing block for absolutely positioned elements is the viewport. 2173 let viewport_cb = Rect { 2174 x: 0.0, 2175 y: 0.0, 2176 width: viewport_width, 2177 height: viewport_height, 2178 }; 2179 compute_layout( 2180 &mut root, 2181 0.0, 2182 0.0, 2183 viewport_width, 2184 viewport_width, 2185 viewport_height, 2186 font, 2187 doc, 2188 viewport_cb, 2189 ); 2190 2191 let height = root.margin_box_height(); 2192 LayoutTree { 2193 root, 2194 width: viewport_width, 2195 height, 2196 } 2197} 2198 2199#[cfg(test)] 2200mod tests { 2201 use super::*; 2202 use we_dom::Document; 2203 use we_style::computed::{extract_stylesheets, resolve_styles}; 2204 2205 fn test_font() -> Font { 2206 let paths = [ 2207 "/System/Library/Fonts/Geneva.ttf", 2208 "/System/Library/Fonts/Monaco.ttf", 2209 ]; 2210 for path in &paths { 2211 let p = std::path::Path::new(path); 2212 if p.exists() { 2213 return Font::from_file(p).expect("failed to parse font"); 2214 } 2215 } 2216 panic!("no test font found"); 2217 } 2218 2219 fn layout_doc(doc: &Document) -> LayoutTree { 2220 let font = test_font(); 2221 let sheets = extract_stylesheets(doc); 2222 let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); 2223 layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) 2224 } 2225 2226 #[test] 2227 fn empty_document() { 2228 let doc = Document::new(); 2229 let font = test_font(); 2230 let sheets = extract_stylesheets(&doc); 2231 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)); 2232 if let Some(styled) = styled { 2233 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2234 assert_eq!(tree.width, 800.0); 2235 } 2236 } 2237 2238 #[test] 2239 fn single_paragraph() { 2240 let mut doc = Document::new(); 2241 let root = doc.root(); 2242 let html = doc.create_element("html"); 2243 let body = doc.create_element("body"); 2244 let p = doc.create_element("p"); 2245 let text = doc.create_text("Hello world"); 2246 doc.append_child(root, html); 2247 doc.append_child(html, body); 2248 doc.append_child(body, p); 2249 doc.append_child(p, text); 2250 2251 let tree = layout_doc(&doc); 2252 2253 assert!(matches!(tree.root.box_type, BoxType::Block(_))); 2254 2255 let body_box = &tree.root.children[0]; 2256 assert!(matches!(body_box.box_type, BoxType::Block(_))); 2257 2258 let p_box = &body_box.children[0]; 2259 assert!(matches!(p_box.box_type, BoxType::Block(_))); 2260 2261 assert!(!p_box.lines.is_empty(), "p should have text fragments"); 2262 2263 // Collect all text on the first visual line. 2264 let first_y = p_box.lines[0].y; 2265 let line_text: String = p_box 2266 .lines 2267 .iter() 2268 .filter(|l| (l.y - first_y).abs() < 0.01) 2269 .map(|l| l.text.as_str()) 2270 .collect::<Vec<_>>() 2271 .join(" "); 2272 assert!( 2273 line_text.contains("Hello") && line_text.contains("world"), 2274 "line should contain Hello and world, got: {line_text}" 2275 ); 2276 2277 assert_eq!(p_box.margin.top, 16.0); 2278 assert_eq!(p_box.margin.bottom, 16.0); 2279 } 2280 2281 #[test] 2282 fn blocks_stack_vertically() { 2283 let mut doc = Document::new(); 2284 let root = doc.root(); 2285 let html = doc.create_element("html"); 2286 let body = doc.create_element("body"); 2287 let p1 = doc.create_element("p"); 2288 let t1 = doc.create_text("First"); 2289 let p2 = doc.create_element("p"); 2290 let t2 = doc.create_text("Second"); 2291 doc.append_child(root, html); 2292 doc.append_child(html, body); 2293 doc.append_child(body, p1); 2294 doc.append_child(p1, t1); 2295 doc.append_child(body, p2); 2296 doc.append_child(p2, t2); 2297 2298 let tree = layout_doc(&doc); 2299 let body_box = &tree.root.children[0]; 2300 let first = &body_box.children[0]; 2301 let second = &body_box.children[1]; 2302 2303 assert!( 2304 second.rect.y > first.rect.y, 2305 "second p (y={}) should be below first p (y={})", 2306 second.rect.y, 2307 first.rect.y 2308 ); 2309 } 2310 2311 #[test] 2312 fn heading_larger_than_body() { 2313 let mut doc = Document::new(); 2314 let root = doc.root(); 2315 let html = doc.create_element("html"); 2316 let body = doc.create_element("body"); 2317 let h1 = doc.create_element("h1"); 2318 let h1_text = doc.create_text("Title"); 2319 let p = doc.create_element("p"); 2320 let p_text = doc.create_text("Text"); 2321 doc.append_child(root, html); 2322 doc.append_child(html, body); 2323 doc.append_child(body, h1); 2324 doc.append_child(h1, h1_text); 2325 doc.append_child(body, p); 2326 doc.append_child(p, p_text); 2327 2328 let tree = layout_doc(&doc); 2329 let body_box = &tree.root.children[0]; 2330 let h1_box = &body_box.children[0]; 2331 let p_box = &body_box.children[1]; 2332 2333 assert!( 2334 h1_box.font_size > p_box.font_size, 2335 "h1 font_size ({}) should be > p font_size ({})", 2336 h1_box.font_size, 2337 p_box.font_size 2338 ); 2339 assert_eq!(h1_box.font_size, 32.0); 2340 2341 assert!( 2342 h1_box.rect.height > p_box.rect.height, 2343 "h1 height ({}) should be > p height ({})", 2344 h1_box.rect.height, 2345 p_box.rect.height 2346 ); 2347 } 2348 2349 #[test] 2350 fn body_has_default_margin() { 2351 let mut doc = Document::new(); 2352 let root = doc.root(); 2353 let html = doc.create_element("html"); 2354 let body = doc.create_element("body"); 2355 let p = doc.create_element("p"); 2356 let text = doc.create_text("Test"); 2357 doc.append_child(root, html); 2358 doc.append_child(html, body); 2359 doc.append_child(body, p); 2360 doc.append_child(p, text); 2361 2362 let tree = layout_doc(&doc); 2363 let body_box = &tree.root.children[0]; 2364 2365 // body default margin is 8px, but it collapses with p's 16px margin 2366 // (parent-child collapsing: no border/padding on body). 2367 assert_eq!(body_box.margin.top, 16.0); 2368 assert_eq!(body_box.margin.right, 8.0); 2369 assert_eq!(body_box.margin.bottom, 16.0); 2370 assert_eq!(body_box.margin.left, 8.0); 2371 2372 assert_eq!(body_box.rect.x, 8.0); 2373 // body.rect.y = collapsed margin (16) from viewport top. 2374 assert_eq!(body_box.rect.y, 16.0); 2375 } 2376 2377 #[test] 2378 fn text_wraps_at_container_width() { 2379 let mut doc = Document::new(); 2380 let root = doc.root(); 2381 let html = doc.create_element("html"); 2382 let body = doc.create_element("body"); 2383 let p = doc.create_element("p"); 2384 let text = 2385 doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); 2386 doc.append_child(root, html); 2387 doc.append_child(html, body); 2388 doc.append_child(body, p); 2389 doc.append_child(p, text); 2390 2391 let font = test_font(); 2392 let sheets = extract_stylesheets(&doc); 2393 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2394 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 2395 let body_box = &tree.root.children[0]; 2396 let p_box = &body_box.children[0]; 2397 2398 // Count distinct y-positions to count visual lines. 2399 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2400 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2401 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2402 2403 assert!( 2404 ys.len() > 1, 2405 "text should wrap to multiple lines, got {} visual lines", 2406 ys.len() 2407 ); 2408 } 2409 2410 #[test] 2411 fn layout_produces_positive_dimensions() { 2412 let mut doc = Document::new(); 2413 let root = doc.root(); 2414 let html = doc.create_element("html"); 2415 let body = doc.create_element("body"); 2416 let div = doc.create_element("div"); 2417 let text = doc.create_text("Content"); 2418 doc.append_child(root, html); 2419 doc.append_child(html, body); 2420 doc.append_child(body, div); 2421 doc.append_child(div, text); 2422 2423 let tree = layout_doc(&doc); 2424 2425 for b in tree.iter() { 2426 assert!(b.rect.width >= 0.0, "width should be >= 0"); 2427 assert!(b.rect.height >= 0.0, "height should be >= 0"); 2428 } 2429 2430 assert!(tree.height > 0.0, "layout height should be > 0"); 2431 } 2432 2433 #[test] 2434 fn head_is_hidden() { 2435 let mut doc = Document::new(); 2436 let root = doc.root(); 2437 let html = doc.create_element("html"); 2438 let head = doc.create_element("head"); 2439 let title = doc.create_element("title"); 2440 let title_text = doc.create_text("Page Title"); 2441 let body = doc.create_element("body"); 2442 let p = doc.create_element("p"); 2443 let p_text = doc.create_text("Visible"); 2444 doc.append_child(root, html); 2445 doc.append_child(html, head); 2446 doc.append_child(head, title); 2447 doc.append_child(title, title_text); 2448 doc.append_child(html, body); 2449 doc.append_child(body, p); 2450 doc.append_child(p, p_text); 2451 2452 let tree = layout_doc(&doc); 2453 2454 assert_eq!( 2455 tree.root.children.len(), 2456 1, 2457 "html should have 1 child (body), head should be hidden" 2458 ); 2459 } 2460 2461 #[test] 2462 fn mixed_block_and_inline() { 2463 let mut doc = Document::new(); 2464 let root = doc.root(); 2465 let html = doc.create_element("html"); 2466 let body = doc.create_element("body"); 2467 let div = doc.create_element("div"); 2468 let text1 = doc.create_text("Text"); 2469 let p = doc.create_element("p"); 2470 let p_text = doc.create_text("Block"); 2471 let text2 = doc.create_text("More"); 2472 doc.append_child(root, html); 2473 doc.append_child(html, body); 2474 doc.append_child(body, div); 2475 doc.append_child(div, text1); 2476 doc.append_child(div, p); 2477 doc.append_child(p, p_text); 2478 doc.append_child(div, text2); 2479 2480 let tree = layout_doc(&doc); 2481 let body_box = &tree.root.children[0]; 2482 let div_box = &body_box.children[0]; 2483 2484 assert_eq!( 2485 div_box.children.len(), 2486 3, 2487 "div should have 3 children (anon, block, anon), got {}", 2488 div_box.children.len() 2489 ); 2490 2491 assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); 2492 assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); 2493 assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); 2494 } 2495 2496 #[test] 2497 fn inline_elements_contribute_text() { 2498 let mut doc = Document::new(); 2499 let root = doc.root(); 2500 let html = doc.create_element("html"); 2501 let body = doc.create_element("body"); 2502 let p = doc.create_element("p"); 2503 let t1 = doc.create_text("Hello "); 2504 let em = doc.create_element("em"); 2505 let t2 = doc.create_text("world"); 2506 let t3 = doc.create_text("!"); 2507 doc.append_child(root, html); 2508 doc.append_child(html, body); 2509 doc.append_child(body, p); 2510 doc.append_child(p, t1); 2511 doc.append_child(p, em); 2512 doc.append_child(em, t2); 2513 doc.append_child(p, t3); 2514 2515 let tree = layout_doc(&doc); 2516 let body_box = &tree.root.children[0]; 2517 let p_box = &body_box.children[0]; 2518 2519 assert!(!p_box.lines.is_empty()); 2520 2521 let first_y = p_box.lines[0].y; 2522 let line_texts: Vec<&str> = p_box 2523 .lines 2524 .iter() 2525 .filter(|l| (l.y - first_y).abs() < 0.01) 2526 .map(|l| l.text.as_str()) 2527 .collect(); 2528 let combined = line_texts.join(""); 2529 assert!( 2530 combined.contains("Hello") && combined.contains("world") && combined.contains("!"), 2531 "line should contain all text, got: {combined}" 2532 ); 2533 } 2534 2535 #[test] 2536 fn collapse_whitespace_works() { 2537 assert_eq!(collapse_whitespace("hello world"), "hello world"); 2538 assert_eq!(collapse_whitespace(" spaces "), " spaces "); 2539 assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); 2540 assert_eq!(collapse_whitespace("no-extra"), "no-extra"); 2541 assert_eq!(collapse_whitespace(" "), " "); 2542 } 2543 2544 #[test] 2545 fn content_width_respects_body_margin() { 2546 let mut doc = Document::new(); 2547 let root = doc.root(); 2548 let html = doc.create_element("html"); 2549 let body = doc.create_element("body"); 2550 let div = doc.create_element("div"); 2551 let text = doc.create_text("Content"); 2552 doc.append_child(root, html); 2553 doc.append_child(html, body); 2554 doc.append_child(body, div); 2555 doc.append_child(div, text); 2556 2557 let font = test_font(); 2558 let sheets = extract_stylesheets(&doc); 2559 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2560 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2561 let body_box = &tree.root.children[0]; 2562 2563 assert_eq!(body_box.rect.width, 784.0); 2564 2565 let div_box = &body_box.children[0]; 2566 assert_eq!(div_box.rect.width, 784.0); 2567 } 2568 2569 #[test] 2570 fn multiple_heading_levels() { 2571 let mut doc = Document::new(); 2572 let root = doc.root(); 2573 let html = doc.create_element("html"); 2574 let body = doc.create_element("body"); 2575 doc.append_child(root, html); 2576 doc.append_child(html, body); 2577 2578 let tags = ["h1", "h2", "h3"]; 2579 for tag in &tags { 2580 let h = doc.create_element(tag); 2581 let t = doc.create_text(tag); 2582 doc.append_child(body, h); 2583 doc.append_child(h, t); 2584 } 2585 2586 let tree = layout_doc(&doc); 2587 let body_box = &tree.root.children[0]; 2588 2589 let h1 = &body_box.children[0]; 2590 let h2 = &body_box.children[1]; 2591 let h3 = &body_box.children[2]; 2592 assert!(h1.font_size > h2.font_size); 2593 assert!(h2.font_size > h3.font_size); 2594 2595 assert!(h2.rect.y > h1.rect.y); 2596 assert!(h3.rect.y > h2.rect.y); 2597 } 2598 2599 #[test] 2600 fn layout_tree_iteration() { 2601 let mut doc = Document::new(); 2602 let root = doc.root(); 2603 let html = doc.create_element("html"); 2604 let body = doc.create_element("body"); 2605 let p = doc.create_element("p"); 2606 let text = doc.create_text("Test"); 2607 doc.append_child(root, html); 2608 doc.append_child(html, body); 2609 doc.append_child(body, p); 2610 doc.append_child(p, text); 2611 2612 let tree = layout_doc(&doc); 2613 let count = tree.iter().count(); 2614 assert!(count >= 3, "should have at least html, body, p boxes"); 2615 } 2616 2617 #[test] 2618 fn css_style_affects_layout() { 2619 let html_str = r#"<!DOCTYPE html> 2620<html> 2621<head> 2622<style> 2623p { margin-top: 50px; margin-bottom: 50px; } 2624</style> 2625</head> 2626<body> 2627<p>First</p> 2628<p>Second</p> 2629</body> 2630</html>"#; 2631 let doc = we_html::parse_html(html_str); 2632 let font = test_font(); 2633 let sheets = extract_stylesheets(&doc); 2634 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2635 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2636 2637 let body_box = &tree.root.children[0]; 2638 let first = &body_box.children[0]; 2639 let second = &body_box.children[1]; 2640 2641 assert_eq!(first.margin.top, 50.0); 2642 assert_eq!(first.margin.bottom, 50.0); 2643 2644 // Adjacent sibling margins collapse: gap = max(50, 50) = 50, not 100. 2645 let gap = second.rect.y - (first.rect.y + first.rect.height); 2646 assert!( 2647 (gap - 50.0).abs() < 1.0, 2648 "collapsed margin gap should be ~50px, got {gap}" 2649 ); 2650 } 2651 2652 #[test] 2653 fn inline_style_affects_layout() { 2654 let html_str = r#"<!DOCTYPE html> 2655<html> 2656<body> 2657<div style="padding-top: 20px; padding-bottom: 20px;"> 2658<p>Content</p> 2659</div> 2660</body> 2661</html>"#; 2662 let doc = we_html::parse_html(html_str); 2663 let font = test_font(); 2664 let sheets = extract_stylesheets(&doc); 2665 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2666 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2667 2668 let body_box = &tree.root.children[0]; 2669 let div_box = &body_box.children[0]; 2670 2671 assert_eq!(div_box.padding.top, 20.0); 2672 assert_eq!(div_box.padding.bottom, 20.0); 2673 } 2674 2675 #[test] 2676 fn css_color_propagates_to_layout() { 2677 let html_str = r#"<!DOCTYPE html> 2678<html> 2679<head><style>p { color: red; background-color: blue; }</style></head> 2680<body><p>Colored</p></body> 2681</html>"#; 2682 let doc = we_html::parse_html(html_str); 2683 let font = test_font(); 2684 let sheets = extract_stylesheets(&doc); 2685 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2686 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2687 2688 let body_box = &tree.root.children[0]; 2689 let p_box = &body_box.children[0]; 2690 2691 assert_eq!(p_box.color, Color::rgb(255, 0, 0)); 2692 assert_eq!(p_box.background_color, Color::rgb(0, 0, 255)); 2693 } 2694 2695 // --- New inline layout tests --- 2696 2697 #[test] 2698 fn inline_elements_have_per_fragment_styling() { 2699 let html_str = r#"<!DOCTYPE html> 2700<html> 2701<head><style>em { color: red; }</style></head> 2702<body><p>Hello <em>world</em></p></body> 2703</html>"#; 2704 let doc = we_html::parse_html(html_str); 2705 let font = test_font(); 2706 let sheets = extract_stylesheets(&doc); 2707 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2708 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2709 2710 let body_box = &tree.root.children[0]; 2711 let p_box = &body_box.children[0]; 2712 2713 let colors: Vec<Color> = p_box.lines.iter().map(|l| l.color).collect(); 2714 assert!( 2715 colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), 2716 "should have black text" 2717 ); 2718 assert!( 2719 colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), 2720 "should have red text from <em>" 2721 ); 2722 } 2723 2724 #[test] 2725 fn br_element_forces_line_break() { 2726 let html_str = r#"<!DOCTYPE html> 2727<html> 2728<body><p>Line one<br>Line two</p></body> 2729</html>"#; 2730 let doc = we_html::parse_html(html_str); 2731 let font = test_font(); 2732 let sheets = extract_stylesheets(&doc); 2733 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2734 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2735 2736 let body_box = &tree.root.children[0]; 2737 let p_box = &body_box.children[0]; 2738 2739 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2740 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2741 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2742 2743 assert!( 2744 ys.len() >= 2, 2745 "<br> should produce 2 visual lines, got {}", 2746 ys.len() 2747 ); 2748 } 2749 2750 #[test] 2751 fn text_align_center() { 2752 let html_str = r#"<!DOCTYPE html> 2753<html> 2754<head><style>p { text-align: center; }</style></head> 2755<body><p>Hi</p></body> 2756</html>"#; 2757 let doc = we_html::parse_html(html_str); 2758 let font = test_font(); 2759 let sheets = extract_stylesheets(&doc); 2760 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2761 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2762 2763 let body_box = &tree.root.children[0]; 2764 let p_box = &body_box.children[0]; 2765 2766 assert!(!p_box.lines.is_empty()); 2767 let first = &p_box.lines[0]; 2768 // Center-aligned: text should be noticeably offset from content x. 2769 assert!( 2770 first.x > p_box.rect.x + 10.0, 2771 "center-aligned text x ({}) should be offset from content x ({})", 2772 first.x, 2773 p_box.rect.x 2774 ); 2775 } 2776 2777 #[test] 2778 fn text_align_right() { 2779 let html_str = r#"<!DOCTYPE html> 2780<html> 2781<head><style>p { text-align: right; }</style></head> 2782<body><p>Hi</p></body> 2783</html>"#; 2784 let doc = we_html::parse_html(html_str); 2785 let font = test_font(); 2786 let sheets = extract_stylesheets(&doc); 2787 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2788 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2789 2790 let body_box = &tree.root.children[0]; 2791 let p_box = &body_box.children[0]; 2792 2793 assert!(!p_box.lines.is_empty()); 2794 let first = &p_box.lines[0]; 2795 let right_edge = p_box.rect.x + p_box.rect.width; 2796 assert!( 2797 (first.x + first.width - right_edge).abs() < 1.0, 2798 "right-aligned text end ({}) should be near right edge ({})", 2799 first.x + first.width, 2800 right_edge 2801 ); 2802 } 2803 2804 #[test] 2805 fn inline_padding_offsets_text() { 2806 let html_str = r#"<!DOCTYPE html> 2807<html> 2808<head><style>span { padding-left: 20px; padding-right: 20px; }</style></head> 2809<body><p>A<span>B</span>C</p></body> 2810</html>"#; 2811 let doc = we_html::parse_html(html_str); 2812 let font = test_font(); 2813 let sheets = extract_stylesheets(&doc); 2814 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2815 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2816 2817 let body_box = &tree.root.children[0]; 2818 let p_box = &body_box.children[0]; 2819 2820 // Should have at least 3 fragments: A, B, C 2821 assert!( 2822 p_box.lines.len() >= 3, 2823 "should have fragments for A, B, C, got {}", 2824 p_box.lines.len() 2825 ); 2826 2827 // B should be offset by the span's padding. 2828 let a_frag = &p_box.lines[0]; 2829 let b_frag = &p_box.lines[1]; 2830 let gap = b_frag.x - (a_frag.x + a_frag.width); 2831 // Gap should include the 20px padding-left from the span. 2832 assert!( 2833 gap >= 19.0, 2834 "gap between A and B ({gap}) should include span padding-left (20px)" 2835 ); 2836 } 2837 2838 #[test] 2839 fn text_fragments_have_correct_font_size() { 2840 let html_str = r#"<!DOCTYPE html> 2841<html> 2842<body><h1>Big</h1><p>Small</p></body> 2843</html>"#; 2844 let doc = we_html::parse_html(html_str); 2845 let font = test_font(); 2846 let sheets = extract_stylesheets(&doc); 2847 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2848 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2849 2850 let body_box = &tree.root.children[0]; 2851 let h1_box = &body_box.children[0]; 2852 let p_box = &body_box.children[1]; 2853 2854 assert!(!h1_box.lines.is_empty()); 2855 assert!(!p_box.lines.is_empty()); 2856 assert_eq!(h1_box.lines[0].font_size, 32.0); 2857 assert_eq!(p_box.lines[0].font_size, 16.0); 2858 } 2859 2860 #[test] 2861 fn line_height_from_computed_style() { 2862 let html_str = r#"<!DOCTYPE html> 2863<html> 2864<head><style>p { line-height: 30px; }</style></head> 2865<body><p>Line one Line two Line three</p></body> 2866</html>"#; 2867 let doc = we_html::parse_html(html_str); 2868 let font = test_font(); 2869 let sheets = extract_stylesheets(&doc); 2870 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2871 // Narrow viewport to force wrapping. 2872 let tree = layout(&styled, &doc, 100.0, 600.0, &font, &HashMap::new()); 2873 2874 let body_box = &tree.root.children[0]; 2875 let p_box = &body_box.children[0]; 2876 2877 let mut ys: Vec<f32> = p_box.lines.iter().map(|l| l.y).collect(); 2878 ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); 2879 ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); 2880 2881 if ys.len() >= 2 { 2882 let gap = ys[1] - ys[0]; 2883 assert!( 2884 (gap - 30.0).abs() < 1.0, 2885 "line spacing ({gap}) should be ~30px from line-height" 2886 ); 2887 } 2888 } 2889 2890 // --- Relative positioning tests --- 2891 2892 #[test] 2893 fn relative_position_top_left() { 2894 let html_str = r#"<!DOCTYPE html> 2895<html> 2896<body> 2897<div style="position: relative; top: 10px; left: 20px;">Content</div> 2898</body> 2899</html>"#; 2900 let doc = we_html::parse_html(html_str); 2901 let font = test_font(); 2902 let sheets = extract_stylesheets(&doc); 2903 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2904 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2905 2906 let body_box = &tree.root.children[0]; 2907 let div_box = &body_box.children[0]; 2908 2909 assert_eq!(div_box.position, Position::Relative); 2910 assert_eq!(div_box.relative_offset, (20.0, 10.0)); 2911 2912 // The div should be shifted from where it would be in normal flow. 2913 // Normal flow position: body.rect.x + margin, body.rect.y + margin. 2914 // With relative offset: shifted by (20, 10). 2915 // Body has 8px margin by default, so content starts at x=8, y=8. 2916 assert!( 2917 (div_box.rect.x - (8.0 + 20.0)).abs() < 0.01, 2918 "div x ({}) should be 28.0 (8 + 20)", 2919 div_box.rect.x 2920 ); 2921 assert!( 2922 (div_box.rect.y - (8.0 + 10.0)).abs() < 0.01, 2923 "div y ({}) should be 18.0 (8 + 10)", 2924 div_box.rect.y 2925 ); 2926 } 2927 2928 #[test] 2929 fn relative_position_does_not_affect_siblings() { 2930 let html_str = r#"<!DOCTYPE html> 2931<html> 2932<head><style> 2933p { margin: 0; } 2934</style></head> 2935<body> 2936<p id="first" style="position: relative; top: 50px;">First</p> 2937<p id="second">Second</p> 2938</body> 2939</html>"#; 2940 let doc = we_html::parse_html(html_str); 2941 let font = test_font(); 2942 let sheets = extract_stylesheets(&doc); 2943 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2944 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2945 2946 let body_box = &tree.root.children[0]; 2947 let first = &body_box.children[0]; 2948 let second = &body_box.children[1]; 2949 2950 // The first paragraph is shifted down by 50px visually. 2951 assert_eq!(first.relative_offset, (0.0, 50.0)); 2952 2953 // But the second paragraph should be at its normal-flow position, 2954 // as if the first paragraph were NOT shifted. The second paragraph 2955 // should come right after the first's normal-flow height. 2956 // Body content starts at y=8 (default body margin). First p has 0 margin. 2957 // Second p should start right after first p's height (without offset). 2958 let first_normal_y = 8.0; // body margin 2959 let first_height = first.rect.height; 2960 let expected_second_y = first_normal_y + first_height; 2961 assert!( 2962 (second.rect.y - expected_second_y).abs() < 1.0, 2963 "second y ({}) should be at normal-flow position ({expected_second_y}), not affected by first's relative offset", 2964 second.rect.y 2965 ); 2966 } 2967 2968 #[test] 2969 fn relative_position_conflicting_offsets() { 2970 // When both top and bottom are specified, top wins. 2971 // When both left and right are specified, left wins. 2972 let html_str = r#"<!DOCTYPE html> 2973<html> 2974<body> 2975<div style="position: relative; top: 10px; bottom: 20px; left: 30px; right: 40px;">Content</div> 2976</body> 2977</html>"#; 2978 let doc = we_html::parse_html(html_str); 2979 let font = test_font(); 2980 let sheets = extract_stylesheets(&doc); 2981 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 2982 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2983 2984 let body_box = &tree.root.children[0]; 2985 let div_box = &body_box.children[0]; 2986 2987 // top wins over bottom: dy = 10 (not -20) 2988 // left wins over right: dx = 30 (not -40) 2989 assert_eq!(div_box.relative_offset, (30.0, 10.0)); 2990 } 2991 2992 #[test] 2993 fn relative_position_auto_offsets() { 2994 // auto offsets should resolve to 0 (no movement). 2995 let html_str = r#"<!DOCTYPE html> 2996<html> 2997<body> 2998<div style="position: relative;">Content</div> 2999</body> 3000</html>"#; 3001 let doc = we_html::parse_html(html_str); 3002 let font = test_font(); 3003 let sheets = extract_stylesheets(&doc); 3004 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3005 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3006 3007 let body_box = &tree.root.children[0]; 3008 let div_box = &body_box.children[0]; 3009 3010 assert_eq!(div_box.position, Position::Relative); 3011 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 3012 } 3013 3014 #[test] 3015 fn relative_position_bottom_right() { 3016 // bottom: 15px should shift up by 15px (negative direction). 3017 // right: 25px should shift left by 25px (negative direction). 3018 let html_str = r#"<!DOCTYPE html> 3019<html> 3020<body> 3021<div style="position: relative; bottom: 15px; right: 25px;">Content</div> 3022</body> 3023</html>"#; 3024 let doc = we_html::parse_html(html_str); 3025 let font = test_font(); 3026 let sheets = extract_stylesheets(&doc); 3027 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3028 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3029 3030 let body_box = &tree.root.children[0]; 3031 let div_box = &body_box.children[0]; 3032 3033 assert_eq!(div_box.relative_offset, (-25.0, -15.0)); 3034 } 3035 3036 #[test] 3037 fn relative_position_shifts_text_lines() { 3038 let html_str = r#"<!DOCTYPE html> 3039<html> 3040<head><style>p { margin: 0; }</style></head> 3041<body> 3042<p style="position: relative; top: 30px; left: 40px;">Hello</p> 3043</body> 3044</html>"#; 3045 let doc = we_html::parse_html(html_str); 3046 let font = test_font(); 3047 let sheets = extract_stylesheets(&doc); 3048 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3049 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3050 3051 let body_box = &tree.root.children[0]; 3052 let p_box = &body_box.children[0]; 3053 3054 assert!(!p_box.lines.is_empty(), "p should have text lines"); 3055 let first_line = &p_box.lines[0]; 3056 3057 // Text should be shifted by the relative offset. 3058 // Body content starts at x=8, y=8. With offset: x=48, y=38. 3059 assert!( 3060 first_line.x >= 8.0 + 40.0 - 1.0, 3061 "text x ({}) should be shifted by left offset", 3062 first_line.x 3063 ); 3064 assert!( 3065 first_line.y >= 8.0 + 30.0 - 1.0, 3066 "text y ({}) should be shifted by top offset", 3067 first_line.y 3068 ); 3069 } 3070 3071 #[test] 3072 fn static_position_has_no_offset() { 3073 let html_str = r#"<!DOCTYPE html> 3074<html> 3075<body> 3076<div>Normal flow</div> 3077</body> 3078</html>"#; 3079 let doc = we_html::parse_html(html_str); 3080 let font = test_font(); 3081 let sheets = extract_stylesheets(&doc); 3082 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3083 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3084 3085 let body_box = &tree.root.children[0]; 3086 let div_box = &body_box.children[0]; 3087 3088 assert_eq!(div_box.position, Position::Static); 3089 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 3090 } 3091 3092 // --- Margin collapsing tests --- 3093 3094 #[test] 3095 fn adjacent_sibling_margins_collapse() { 3096 // Two <p> elements each with margin 16px: gap should be 16px (max), not 32px (sum). 3097 let html_str = r#"<!DOCTYPE html> 3098<html> 3099<head><style> 3100body { margin: 0; border-top: 1px solid black; } 3101p { margin-top: 16px; margin-bottom: 16px; } 3102</style></head> 3103<body> 3104<p>First</p> 3105<p>Second</p> 3106</body> 3107</html>"#; 3108 let doc = we_html::parse_html(html_str); 3109 let font = test_font(); 3110 let sheets = extract_stylesheets(&doc); 3111 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3112 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3113 3114 let body_box = &tree.root.children[0]; 3115 let first = &body_box.children[0]; 3116 let second = &body_box.children[1]; 3117 3118 // Gap between first's bottom border-box and second's top border-box 3119 // should be the collapsed margin: max(16, 16) = 16. 3120 let first_bottom = 3121 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3122 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3123 assert!( 3124 (gap - 16.0).abs() < 1.0, 3125 "collapsed sibling margin should be ~16px, got {gap}" 3126 ); 3127 } 3128 3129 #[test] 3130 fn sibling_margins_collapse_unequal() { 3131 // p1 bottom-margin 20, p2 top-margin 30: gap should be 30 (max). 3132 let html_str = r#"<!DOCTYPE html> 3133<html> 3134<head><style> 3135body { margin: 0; border-top: 1px solid black; } 3136.first { margin-top: 0; margin-bottom: 20px; } 3137.second { margin-top: 30px; margin-bottom: 0; } 3138</style></head> 3139<body> 3140<p class="first">First</p> 3141<p class="second">Second</p> 3142</body> 3143</html>"#; 3144 let doc = we_html::parse_html(html_str); 3145 let font = test_font(); 3146 let sheets = extract_stylesheets(&doc); 3147 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3148 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3149 3150 let body_box = &tree.root.children[0]; 3151 let first = &body_box.children[0]; 3152 let second = &body_box.children[1]; 3153 3154 let first_bottom = 3155 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3156 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3157 assert!( 3158 (gap - 30.0).abs() < 1.0, 3159 "collapsed margin should be max(20, 30) = 30, got {gap}" 3160 ); 3161 } 3162 3163 #[test] 3164 fn parent_first_child_margin_collapsing() { 3165 // Parent with no padding/border: first child's top margin collapses. 3166 let html_str = r#"<!DOCTYPE html> 3167<html> 3168<head><style> 3169body { margin: 0; border-top: 1px solid black; } 3170.parent { margin-top: 10px; } 3171.child { margin-top: 20px; } 3172</style></head> 3173<body> 3174<div class="parent"><p class="child">Child</p></div> 3175</body> 3176</html>"#; 3177 let doc = we_html::parse_html(html_str); 3178 let font = test_font(); 3179 let sheets = extract_stylesheets(&doc); 3180 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3181 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3182 3183 let body_box = &tree.root.children[0]; 3184 let parent_box = &body_box.children[0]; 3185 3186 // Parent margin collapses with child's: max(10, 20) = 20. 3187 assert_eq!(parent_box.margin.top, 20.0); 3188 } 3189 3190 #[test] 3191 fn negative_margin_collapsing() { 3192 // One positive (20) and one negative (-10): collapsed = 20 + (-10) = 10. 3193 let html_str = r#"<!DOCTYPE html> 3194<html> 3195<head><style> 3196body { margin: 0; border-top: 1px solid black; } 3197.first { margin-top: 0; margin-bottom: 20px; } 3198.second { margin-top: -10px; margin-bottom: 0; } 3199</style></head> 3200<body> 3201<p class="first">First</p> 3202<p class="second">Second</p> 3203</body> 3204</html>"#; 3205 let doc = we_html::parse_html(html_str); 3206 let font = test_font(); 3207 let sheets = extract_stylesheets(&doc); 3208 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3209 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3210 3211 let body_box = &tree.root.children[0]; 3212 let first = &body_box.children[0]; 3213 let second = &body_box.children[1]; 3214 3215 let first_bottom = 3216 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3217 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3218 // 20 + (-10) = 10 3219 assert!( 3220 (gap - 10.0).abs() < 1.0, 3221 "positive + negative margin collapse should be 10, got {gap}" 3222 ); 3223 } 3224 3225 #[test] 3226 fn both_negative_margins_collapse() { 3227 // Both negative: use the more negative value. 3228 let html_str = r#"<!DOCTYPE html> 3229<html> 3230<head><style> 3231body { margin: 0; border-top: 1px solid black; } 3232.first { margin-top: 0; margin-bottom: -10px; } 3233.second { margin-top: -20px; margin-bottom: 0; } 3234</style></head> 3235<body> 3236<p class="first">First</p> 3237<p class="second">Second</p> 3238</body> 3239</html>"#; 3240 let doc = we_html::parse_html(html_str); 3241 let font = test_font(); 3242 let sheets = extract_stylesheets(&doc); 3243 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3244 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3245 3246 let body_box = &tree.root.children[0]; 3247 let first = &body_box.children[0]; 3248 let second = &body_box.children[1]; 3249 3250 let first_bottom = 3251 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3252 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3253 // Both negative: min(-10, -20) = -20 3254 assert!( 3255 (gap - (-20.0)).abs() < 1.0, 3256 "both-negative margin collapse should be -20, got {gap}" 3257 ); 3258 } 3259 3260 #[test] 3261 fn border_blocks_margin_collapsing() { 3262 // When border separates margins, they don't collapse. 3263 let html_str = r#"<!DOCTYPE html> 3264<html> 3265<head><style> 3266body { margin: 0; border-top: 1px solid black; } 3267.first { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid black; } 3268.second { margin-top: 20px; border-top: 1px solid black; } 3269</style></head> 3270<body> 3271<p class="first">First</p> 3272<p class="second">Second</p> 3273</body> 3274</html>"#; 3275 let doc = we_html::parse_html(html_str); 3276 let font = test_font(); 3277 let sheets = extract_stylesheets(&doc); 3278 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3279 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3280 3281 let body_box = &tree.root.children[0]; 3282 let first = &body_box.children[0]; 3283 let second = &body_box.children[1]; 3284 3285 // Borders are on the elements themselves, but the MARGINS are still 3286 // between the border boxes — sibling margins still collapse regardless 3287 // of borders on the elements. The margin gap = max(20, 20) = 20. 3288 let first_bottom = 3289 first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 3290 let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 3291 assert!( 3292 (gap - 20.0).abs() < 1.0, 3293 "sibling margins collapse even with borders on elements, gap should be 20, got {gap}" 3294 ); 3295 } 3296 3297 #[test] 3298 fn padding_blocks_parent_child_collapsing() { 3299 // Parent with padding-top prevents margin collapsing with first child. 3300 let html_str = r#"<!DOCTYPE html> 3301<html> 3302<head><style> 3303body { margin: 0; border-top: 1px solid black; } 3304.parent { margin-top: 10px; padding-top: 5px; } 3305.child { margin-top: 20px; } 3306</style></head> 3307<body> 3308<div class="parent"><p class="child">Child</p></div> 3309</body> 3310</html>"#; 3311 let doc = we_html::parse_html(html_str); 3312 let font = test_font(); 3313 let sheets = extract_stylesheets(&doc); 3314 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3315 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3316 3317 let body_box = &tree.root.children[0]; 3318 let parent_box = &body_box.children[0]; 3319 3320 // Parent has padding-top, so no collapsing: margin stays at 10. 3321 assert_eq!(parent_box.margin.top, 10.0); 3322 } 3323 3324 #[test] 3325 fn empty_block_margins_collapse() { 3326 // An empty div's top and bottom margins collapse with adjacent margins. 3327 let html_str = r#"<!DOCTYPE html> 3328<html> 3329<head><style> 3330body { margin: 0; border-top: 1px solid black; } 3331.spacer { margin-top: 10px; margin-bottom: 10px; } 3332p { margin-top: 5px; margin-bottom: 5px; } 3333</style></head> 3334<body> 3335<p>Before</p> 3336<div class="spacer"></div> 3337<p>After</p> 3338</body> 3339</html>"#; 3340 let doc = we_html::parse_html(html_str); 3341 let font = test_font(); 3342 let sheets = extract_stylesheets(&doc); 3343 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3344 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3345 3346 let body_box = &tree.root.children[0]; 3347 let before = &body_box.children[0]; 3348 let after = &body_box.children[2]; // [0]=p, [1]=empty div, [2]=p 3349 3350 // Empty div's margins (10+10) self-collapse to max(10,10)=10. 3351 // Then collapse with before's bottom (5) and after's top (5): 3352 // collapse(5, collapse(10, 10)) = collapse(5, 10) = 10 3353 // Then collapse(10, 5) = 10. 3354 // So total gap between before and after = 10. 3355 let before_bottom = 3356 before.rect.y + before.rect.height + before.padding.bottom + before.border.bottom; 3357 let gap = after.rect.y - after.border.top - after.padding.top - before_bottom; 3358 assert!( 3359 (gap - 10.0).abs() < 1.0, 3360 "empty block margin collapse gap should be ~10px, got {gap}" 3361 ); 3362 } 3363 3364 #[test] 3365 fn collapse_margins_unit() { 3366 // Unit tests for the collapse_margins helper. 3367 assert_eq!(collapse_margins(10.0, 20.0), 20.0); 3368 assert_eq!(collapse_margins(20.0, 10.0), 20.0); 3369 assert_eq!(collapse_margins(0.0, 15.0), 15.0); 3370 assert_eq!(collapse_margins(-5.0, -10.0), -10.0); 3371 assert_eq!(collapse_margins(20.0, -5.0), 15.0); 3372 assert_eq!(collapse_margins(-5.0, 20.0), 15.0); 3373 assert_eq!(collapse_margins(0.0, 0.0), 0.0); 3374 } 3375 3376 // --- Box-sizing tests --- 3377 3378 #[test] 3379 fn content_box_default_width_applies_to_content() { 3380 // Default box-sizing (content-box): width = content width only. 3381 let html_str = r#"<!DOCTYPE html> 3382<html> 3383<head><style> 3384body { margin: 0; } 3385div { width: 200px; padding: 10px; border: 5px solid black; } 3386</style></head> 3387<body> 3388<div>Content</div> 3389</body> 3390</html>"#; 3391 let doc = we_html::parse_html(html_str); 3392 let font = test_font(); 3393 let sheets = extract_stylesheets(&doc); 3394 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3395 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3396 3397 let body_box = &tree.root.children[0]; 3398 let div_box = &body_box.children[0]; 3399 3400 // content-box: rect.width = 200 (the specified width IS the content) 3401 assert_eq!(div_box.rect.width, 200.0); 3402 assert_eq!(div_box.padding.left, 10.0); 3403 assert_eq!(div_box.padding.right, 10.0); 3404 assert_eq!(div_box.border.left, 5.0); 3405 assert_eq!(div_box.border.right, 5.0); 3406 } 3407 3408 #[test] 3409 fn border_box_width_includes_padding_and_border() { 3410 // box-sizing: border-box: width includes padding and border. 3411 let html_str = r#"<!DOCTYPE html> 3412<html> 3413<head><style> 3414body { margin: 0; } 3415div { box-sizing: border-box; width: 200px; padding: 10px; border: 5px solid black; } 3416</style></head> 3417<body> 3418<div>Content</div> 3419</body> 3420</html>"#; 3421 let doc = we_html::parse_html(html_str); 3422 let font = test_font(); 3423 let sheets = extract_stylesheets(&doc); 3424 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3425 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3426 3427 let body_box = &tree.root.children[0]; 3428 let div_box = &body_box.children[0]; 3429 3430 // border-box: content width = 200 - 10*2 (padding) - 5*2 (border) = 170 3431 assert_eq!(div_box.rect.width, 170.0); 3432 assert_eq!(div_box.padding.left, 10.0); 3433 assert_eq!(div_box.padding.right, 10.0); 3434 assert_eq!(div_box.border.left, 5.0); 3435 assert_eq!(div_box.border.right, 5.0); 3436 } 3437 3438 #[test] 3439 fn border_box_padding_exceeds_width_clamps_to_zero() { 3440 // border-box with padding+border > specified width: content clamps to 0. 3441 let html_str = r#"<!DOCTYPE html> 3442<html> 3443<head><style> 3444body { margin: 0; } 3445div { box-sizing: border-box; width: 20px; padding: 15px; border: 5px solid black; } 3446</style></head> 3447<body> 3448<div>X</div> 3449</body> 3450</html>"#; 3451 let doc = we_html::parse_html(html_str); 3452 let font = test_font(); 3453 let sheets = extract_stylesheets(&doc); 3454 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3455 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3456 3457 let body_box = &tree.root.children[0]; 3458 let div_box = &body_box.children[0]; 3459 3460 // border-box: content = 20 - 15*2 - 5*2 = 20 - 40 = -20 → clamped to 0 3461 assert_eq!(div_box.rect.width, 0.0); 3462 } 3463 3464 #[test] 3465 fn box_sizing_is_not_inherited() { 3466 // box-sizing is not inherited: child should use default content-box. 3467 let html_str = r#"<!DOCTYPE html> 3468<html> 3469<head><style> 3470body { margin: 0; } 3471.parent { box-sizing: border-box; width: 300px; padding: 10px; border: 5px solid black; } 3472.child { width: 100px; padding: 10px; border: 5px solid black; } 3473</style></head> 3474<body> 3475<div class="parent"><div class="child">Inner</div></div> 3476</body> 3477</html>"#; 3478 let doc = we_html::parse_html(html_str); 3479 let font = test_font(); 3480 let sheets = extract_stylesheets(&doc); 3481 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3482 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3483 3484 let body_box = &tree.root.children[0]; 3485 let parent_box = &body_box.children[0]; 3486 let child_box = &parent_box.children[0]; 3487 3488 // Parent: border-box → content = 300 - 20 - 10 = 270 3489 assert_eq!(parent_box.rect.width, 270.0); 3490 // Child: default content-box → content = 100 (not reduced by padding/border) 3491 assert_eq!(child_box.rect.width, 100.0); 3492 } 3493 3494 #[test] 3495 fn border_box_height() { 3496 // box-sizing: border-box also applies to height. 3497 let html_str = r#"<!DOCTYPE html> 3498<html> 3499<head><style> 3500body { margin: 0; } 3501div { box-sizing: border-box; height: 100px; padding: 10px; border: 5px solid black; } 3502</style></head> 3503<body> 3504<div>Content</div> 3505</body> 3506</html>"#; 3507 let doc = we_html::parse_html(html_str); 3508 let font = test_font(); 3509 let sheets = extract_stylesheets(&doc); 3510 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3511 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3512 3513 let body_box = &tree.root.children[0]; 3514 let div_box = &body_box.children[0]; 3515 3516 // border-box: content height = 100 - 10*2 (padding) - 5*2 (border) = 70 3517 assert_eq!(div_box.rect.height, 70.0); 3518 } 3519 3520 #[test] 3521 fn content_box_explicit_height() { 3522 // content-box: height applies to content only. 3523 let html_str = r#"<!DOCTYPE html> 3524<html> 3525<head><style> 3526body { margin: 0; } 3527div { height: 100px; padding: 10px; border: 5px solid black; } 3528</style></head> 3529<body> 3530<div>Content</div> 3531</body> 3532</html>"#; 3533 let doc = we_html::parse_html(html_str); 3534 let font = test_font(); 3535 let sheets = extract_stylesheets(&doc); 3536 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3537 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3538 3539 let body_box = &tree.root.children[0]; 3540 let div_box = &body_box.children[0]; 3541 3542 // content-box: rect.height = 100 (specified height IS content height) 3543 assert_eq!(div_box.rect.height, 100.0); 3544 } 3545 3546 // --- Visibility / display:none tests --- 3547 3548 #[test] 3549 fn display_none_excludes_from_layout_tree() { 3550 let html_str = r#"<!DOCTYPE html> 3551<html> 3552<head><style> 3553body { margin: 0; } 3554.hidden { display: none; } 3555p { margin: 0; } 3556</style></head> 3557<body> 3558<p>First</p> 3559<div class="hidden">Hidden content</div> 3560<p>Second</p> 3561</body> 3562</html>"#; 3563 let doc = we_html::parse_html(html_str); 3564 let font = test_font(); 3565 let sheets = extract_stylesheets(&doc); 3566 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3567 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3568 3569 let body_box = &tree.root.children[0]; 3570 // display:none element is excluded — body should have only 2 children. 3571 assert_eq!(body_box.children.len(), 2); 3572 3573 let first = &body_box.children[0]; 3574 let second = &body_box.children[1]; 3575 // Second paragraph should be directly below first (no gap for hidden). 3576 assert!( 3577 second.rect.y == first.rect.y + first.rect.height, 3578 "display:none should not occupy space" 3579 ); 3580 } 3581 3582 #[test] 3583 fn visibility_hidden_preserves_layout_space() { 3584 let html_str = r#"<!DOCTYPE html> 3585<html> 3586<head><style> 3587body { margin: 0; } 3588.hidden { visibility: hidden; height: 50px; margin: 0; } 3589p { margin: 0; } 3590</style></head> 3591<body> 3592<p>First</p> 3593<div class="hidden">Hidden</div> 3594<p>Second</p> 3595</body> 3596</html>"#; 3597 let doc = we_html::parse_html(html_str); 3598 let font = test_font(); 3599 let sheets = extract_stylesheets(&doc); 3600 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3601 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3602 3603 let body_box = &tree.root.children[0]; 3604 // visibility:hidden still in layout tree — body has 3 children. 3605 assert_eq!(body_box.children.len(), 3); 3606 3607 let hidden_box = &body_box.children[1]; 3608 assert_eq!(hidden_box.visibility, Visibility::Hidden); 3609 assert_eq!(hidden_box.rect.height, 50.0); 3610 3611 let second = &body_box.children[2]; 3612 // Second paragraph should be below hidden div (it occupies 50px). 3613 assert!( 3614 second.rect.y >= hidden_box.rect.y + 50.0, 3615 "visibility:hidden should preserve layout space" 3616 ); 3617 } 3618 3619 #[test] 3620 fn visibility_inherited_by_children() { 3621 let html_str = r#"<!DOCTYPE html> 3622<html> 3623<head><style> 3624body { margin: 0; } 3625.parent { visibility: hidden; } 3626</style></head> 3627<body> 3628<div class="parent"><p>Child</p></div> 3629</body> 3630</html>"#; 3631 let doc = we_html::parse_html(html_str); 3632 let font = test_font(); 3633 let sheets = extract_stylesheets(&doc); 3634 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3635 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3636 3637 let body_box = &tree.root.children[0]; 3638 let parent_box = &body_box.children[0]; 3639 let child_box = &parent_box.children[0]; 3640 assert_eq!(parent_box.visibility, Visibility::Hidden); 3641 assert_eq!(child_box.visibility, Visibility::Hidden); 3642 } 3643 3644 #[test] 3645 fn visibility_visible_overrides_hidden_parent() { 3646 let html_str = r#"<!DOCTYPE html> 3647<html> 3648<head><style> 3649body { margin: 0; } 3650.parent { visibility: hidden; } 3651.child { visibility: visible; } 3652</style></head> 3653<body> 3654<div class="parent"><p class="child">Visible child</p></div> 3655</body> 3656</html>"#; 3657 let doc = we_html::parse_html(html_str); 3658 let font = test_font(); 3659 let sheets = extract_stylesheets(&doc); 3660 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3661 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3662 3663 let body_box = &tree.root.children[0]; 3664 let parent_box = &body_box.children[0]; 3665 let child_box = &parent_box.children[0]; 3666 assert_eq!(parent_box.visibility, Visibility::Hidden); 3667 assert_eq!(child_box.visibility, Visibility::Visible); 3668 } 3669 3670 #[test] 3671 fn visibility_collapse_on_non_table_treated_as_hidden() { 3672 let html_str = r#"<!DOCTYPE html> 3673<html> 3674<head><style> 3675body { margin: 0; } 3676div { visibility: collapse; height: 50px; } 3677</style></head> 3678<body> 3679<div>Collapsed</div> 3680</body> 3681</html>"#; 3682 let doc = we_html::parse_html(html_str); 3683 let font = test_font(); 3684 let sheets = extract_stylesheets(&doc); 3685 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3686 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3687 3688 let body_box = &tree.root.children[0]; 3689 let div_box = &body_box.children[0]; 3690 assert_eq!(div_box.visibility, Visibility::Collapse); 3691 // Still occupies space (non-table collapse = hidden behavior). 3692 assert_eq!(div_box.rect.height, 50.0); 3693 } 3694 3695 // --- Viewport units and percentage resolution tests --- 3696 3697 #[test] 3698 fn width_50_percent_resolves_to_half_containing_block() { 3699 let html_str = r#"<!DOCTYPE html> 3700<html> 3701<head><style> 3702body { margin: 0; } 3703div { width: 50%; } 3704</style></head> 3705<body><div>Half width</div></body> 3706</html>"#; 3707 let doc = we_html::parse_html(html_str); 3708 let font = test_font(); 3709 let sheets = extract_stylesheets(&doc); 3710 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3711 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3712 3713 let body_box = &tree.root.children[0]; 3714 let div_box = &body_box.children[0]; 3715 assert!( 3716 (div_box.rect.width - 400.0).abs() < 0.01, 3717 "width: 50% should be 400px on 800px viewport, got {}", 3718 div_box.rect.width 3719 ); 3720 } 3721 3722 #[test] 3723 fn margin_10_percent_resolves_against_containing_block_width() { 3724 let html_str = r#"<!DOCTYPE html> 3725<html> 3726<head><style> 3727body { margin: 0; } 3728div { margin: 10%; width: 100px; height: 50px; } 3729</style></head> 3730<body><div>Box</div></body> 3731</html>"#; 3732 let doc = we_html::parse_html(html_str); 3733 let font = test_font(); 3734 let sheets = extract_stylesheets(&doc); 3735 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3736 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3737 3738 let body_box = &tree.root.children[0]; 3739 let div_box = &body_box.children[0]; 3740 // All margins (including top/bottom) resolve against containing block WIDTH. 3741 assert!( 3742 (div_box.margin.left - 80.0).abs() < 0.01, 3743 "margin-left: 10% should be 80px on 800px viewport, got {}", 3744 div_box.margin.left 3745 ); 3746 assert!( 3747 (div_box.margin.top - 80.0).abs() < 0.01, 3748 "margin-top: 10% should be 80px (against width, not height), got {}", 3749 div_box.margin.top 3750 ); 3751 } 3752 3753 #[test] 3754 fn width_50vw_resolves_to_half_viewport() { 3755 let html_str = r#"<!DOCTYPE html> 3756<html> 3757<head><style> 3758body { margin: 0; } 3759div { width: 50vw; } 3760</style></head> 3761<body><div>Half viewport</div></body> 3762</html>"#; 3763 let doc = we_html::parse_html(html_str); 3764 let font = test_font(); 3765 let sheets = extract_stylesheets(&doc); 3766 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3767 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3768 3769 let body_box = &tree.root.children[0]; 3770 let div_box = &body_box.children[0]; 3771 assert!( 3772 (div_box.rect.width - 400.0).abs() < 0.01, 3773 "width: 50vw should be 400px on 800px viewport, got {}", 3774 div_box.rect.width 3775 ); 3776 } 3777 3778 #[test] 3779 fn height_100vh_resolves_to_full_viewport() { 3780 let html_str = r#"<!DOCTYPE html> 3781<html> 3782<head><style> 3783body { margin: 0; } 3784div { height: 100vh; } 3785</style></head> 3786<body><div>Full height</div></body> 3787</html>"#; 3788 let doc = we_html::parse_html(html_str); 3789 let font = test_font(); 3790 let sheets = extract_stylesheets(&doc); 3791 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3792 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3793 3794 let body_box = &tree.root.children[0]; 3795 let div_box = &body_box.children[0]; 3796 assert!( 3797 (div_box.rect.height - 600.0).abs() < 0.01, 3798 "height: 100vh should be 600px on 600px viewport, got {}", 3799 div_box.rect.height 3800 ); 3801 } 3802 3803 #[test] 3804 fn font_size_5vmin_resolves_to_smaller_dimension() { 3805 let html_str = r#"<!DOCTYPE html> 3806<html> 3807<head><style> 3808body { margin: 0; } 3809p { font-size: 5vmin; } 3810</style></head> 3811<body><p>Text</p></body> 3812</html>"#; 3813 let doc = we_html::parse_html(html_str); 3814 let font = test_font(); 3815 let sheets = extract_stylesheets(&doc); 3816 // viewport 800x600 → vmin = 600 3817 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3818 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3819 3820 let body_box = &tree.root.children[0]; 3821 let p_box = &body_box.children[0]; 3822 // 5vmin = 5% of min(800, 600) = 5% of 600 = 30px 3823 assert!( 3824 (p_box.font_size - 30.0).abs() < 0.01, 3825 "font-size: 5vmin should be 30px, got {}", 3826 p_box.font_size 3827 ); 3828 } 3829 3830 #[test] 3831 fn nested_percentage_widths_compound() { 3832 let html_str = r#"<!DOCTYPE html> 3833<html> 3834<head><style> 3835body { margin: 0; } 3836.outer { width: 50%; } 3837.inner { width: 50%; } 3838</style></head> 3839<body> 3840<div class="outer"><div class="inner">Nested</div></div> 3841</body> 3842</html>"#; 3843 let doc = we_html::parse_html(html_str); 3844 let font = test_font(); 3845 let sheets = extract_stylesheets(&doc); 3846 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3847 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3848 3849 let body_box = &tree.root.children[0]; 3850 let outer_box = &body_box.children[0]; 3851 let inner_box = &outer_box.children[0]; 3852 // outer = 50% of 800 = 400 3853 assert!( 3854 (outer_box.rect.width - 400.0).abs() < 0.01, 3855 "outer width should be 400px, got {}", 3856 outer_box.rect.width 3857 ); 3858 // inner = 50% of 400 = 200 3859 assert!( 3860 (inner_box.rect.width - 200.0).abs() < 0.01, 3861 "inner width should be 200px (50% of 400), got {}", 3862 inner_box.rect.width 3863 ); 3864 } 3865 3866 #[test] 3867 fn padding_percent_resolves_against_width() { 3868 let html_str = r#"<!DOCTYPE html> 3869<html> 3870<head><style> 3871body { margin: 0; } 3872div { padding: 5%; width: 400px; } 3873</style></head> 3874<body><div>Content</div></body> 3875</html>"#; 3876 let doc = we_html::parse_html(html_str); 3877 let font = test_font(); 3878 let sheets = extract_stylesheets(&doc); 3879 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3880 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3881 3882 let body_box = &tree.root.children[0]; 3883 let div_box = &body_box.children[0]; 3884 // padding: 5% resolves against containing block width (800px) 3885 assert!( 3886 (div_box.padding.top - 40.0).abs() < 0.01, 3887 "padding-top: 5% should be 40px (5% of 800), got {}", 3888 div_box.padding.top 3889 ); 3890 assert!( 3891 (div_box.padding.left - 40.0).abs() < 0.01, 3892 "padding-left: 5% should be 40px (5% of 800), got {}", 3893 div_box.padding.left 3894 ); 3895 } 3896 3897 #[test] 3898 fn vmax_uses_larger_dimension() { 3899 let html_str = r#"<!DOCTYPE html> 3900<html> 3901<head><style> 3902body { margin: 0; } 3903div { width: 10vmax; } 3904</style></head> 3905<body><div>Content</div></body> 3906</html>"#; 3907 let doc = we_html::parse_html(html_str); 3908 let font = test_font(); 3909 let sheets = extract_stylesheets(&doc); 3910 // viewport 800x600 → vmax = 800 3911 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3912 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3913 3914 let body_box = &tree.root.children[0]; 3915 let div_box = &body_box.children[0]; 3916 // 10vmax = 10% of max(800, 600) = 10% of 800 = 80px 3917 assert!( 3918 (div_box.rect.width - 80.0).abs() < 0.01, 3919 "width: 10vmax should be 80px, got {}", 3920 div_box.rect.width 3921 ); 3922 } 3923 3924 #[test] 3925 fn height_50_percent_resolves_against_viewport() { 3926 let html_str = r#"<!DOCTYPE html> 3927<html> 3928<head><style> 3929body { margin: 0; } 3930div { height: 50%; } 3931</style></head> 3932<body><div>Half height</div></body> 3933</html>"#; 3934 let doc = we_html::parse_html(html_str); 3935 let font = test_font(); 3936 let sheets = extract_stylesheets(&doc); 3937 let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 3938 let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 3939 3940 let body_box = &tree.root.children[0]; 3941 let div_box = &body_box.children[0]; 3942 // height: 50% resolves against viewport height (600) 3943 assert!( 3944 (div_box.rect.height - 300.0).abs() < 0.01, 3945 "height: 50% should be 300px on 600px viewport, got {}", 3946 div_box.rect.height 3947 ); 3948 } 3949 3950 // ----------------------------------------------------------------------- 3951 // Flexbox tests 3952 // ----------------------------------------------------------------------- 3953 3954 #[test] 3955 fn flex_row_default_items_laid_out_horizontally() { 3956 let html_str = r#"<!DOCTYPE html> 3957<html><head><style> 3958body { margin: 0; } 3959.container { display: flex; width: 600px; } 3960.item { width: 100px; height: 50px; } 3961</style></head> 3962<body> 3963<div class="container"> 3964 <div class="item">A</div> 3965 <div class="item">B</div> 3966 <div class="item">C</div> 3967</div> 3968</body></html>"#; 3969 let doc = we_html::parse_html(html_str); 3970 let tree = layout_doc(&doc); 3971 3972 let body = &tree.root.children[0]; 3973 let container = &body.children[0]; 3974 assert_eq!(container.children.len(), 3); 3975 3976 // Items should be at x=0, 100, 200 3977 assert!( 3978 (container.children[0].rect.x - 0.0).abs() < 1.0, 3979 "first item x should be 0, got {}", 3980 container.children[0].rect.x 3981 ); 3982 assert!( 3983 (container.children[1].rect.x - 100.0).abs() < 1.0, 3984 "second item x should be 100, got {}", 3985 container.children[1].rect.x 3986 ); 3987 assert!( 3988 (container.children[2].rect.x - 200.0).abs() < 1.0, 3989 "third item x should be 200, got {}", 3990 container.children[2].rect.x 3991 ); 3992 3993 // All items should have the same y. 3994 let y0 = container.children[0].rect.y; 3995 assert!((container.children[1].rect.y - y0).abs() < 1.0); 3996 assert!((container.children[2].rect.y - y0).abs() < 1.0); 3997 } 3998 3999 #[test] 4000 fn flex_direction_column() { 4001 let html_str = r#"<!DOCTYPE html> 4002<html><head><style> 4003body { margin: 0; } 4004.container { display: flex; flex-direction: column; width: 600px; } 4005.item { height: 50px; } 4006</style></head> 4007<body> 4008<div class="container"> 4009 <div class="item">A</div> 4010 <div class="item">B</div> 4011 <div class="item">C</div> 4012</div> 4013</body></html>"#; 4014 let doc = we_html::parse_html(html_str); 4015 let tree = layout_doc(&doc); 4016 4017 let body = &tree.root.children[0]; 4018 let container = &body.children[0]; 4019 assert_eq!(container.children.len(), 3); 4020 4021 // Items should be stacked vertically at y offsets: 0, 50, 100 (relative to container). 4022 let cy = container.rect.y; 4023 assert!((container.children[0].rect.y - cy).abs() < 1.0); 4024 assert!( 4025 (container.children[1].rect.y - cy - 50.0).abs() < 1.0, 4026 "second item y should be {}, got {}", 4027 cy + 50.0, 4028 container.children[1].rect.y 4029 ); 4030 assert!( 4031 (container.children[2].rect.y - cy - 100.0).abs() < 1.0, 4032 "third item y should be {}, got {}", 4033 cy + 100.0, 4034 container.children[2].rect.y 4035 ); 4036 } 4037 4038 #[test] 4039 fn flex_grow_distributes_space() { 4040 let html_str = r#"<!DOCTYPE html> 4041<html><head><style> 4042body { margin: 0; } 4043.container { display: flex; width: 600px; } 4044.a { flex-grow: 1; flex-basis: 0; height: 50px; } 4045.b { flex-grow: 2; flex-basis: 0; height: 50px; } 4046.c { flex-grow: 1; flex-basis: 0; height: 50px; } 4047</style></head> 4048<body> 4049<div class="container"> 4050 <div class="a">A</div> 4051 <div class="b">B</div> 4052 <div class="c">C</div> 4053</div> 4054</body></html>"#; 4055 let doc = we_html::parse_html(html_str); 4056 let tree = layout_doc(&doc); 4057 4058 let body = &tree.root.children[0]; 4059 let container = &body.children[0]; 4060 4061 // flex-grow 1:2:1 should split 600px as 150:300:150 4062 let a = &container.children[0]; 4063 let b = &container.children[1]; 4064 let c = &container.children[2]; 4065 4066 assert!( 4067 (a.rect.width - 150.0).abs() < 1.0, 4068 "item A width should be 150, got {}", 4069 a.rect.width 4070 ); 4071 assert!( 4072 (b.rect.width - 300.0).abs() < 1.0, 4073 "item B width should be 300, got {}", 4074 b.rect.width 4075 ); 4076 assert!( 4077 (c.rect.width - 150.0).abs() < 1.0, 4078 "item C width should be 150, got {}", 4079 c.rect.width 4080 ); 4081 } 4082 4083 #[test] 4084 fn flex_justify_content_center() { 4085 let html_str = r#"<!DOCTYPE html> 4086<html><head><style> 4087body { margin: 0; } 4088.container { display: flex; width: 600px; justify-content: center; } 4089.item { width: 100px; height: 50px; } 4090</style></head> 4091<body> 4092<div class="container"> 4093 <div class="item">A</div> 4094 <div class="item">B</div> 4095</div> 4096</body></html>"#; 4097 let doc = we_html::parse_html(html_str); 4098 let tree = layout_doc(&doc); 4099 4100 let body = &tree.root.children[0]; 4101 let container = &body.children[0]; 4102 4103 // 600px container, 200px of items, 400px free space, offset = 200. 4104 let a = &container.children[0]; 4105 let b = &container.children[1]; 4106 4107 assert!( 4108 (a.rect.x - 200.0).abs() < 1.0, 4109 "first item x should be 200, got {}", 4110 a.rect.x 4111 ); 4112 assert!( 4113 (b.rect.x - 300.0).abs() < 1.0, 4114 "second item x should be 300, got {}", 4115 b.rect.x 4116 ); 4117 } 4118 4119 #[test] 4120 fn flex_justify_content_space_between() { 4121 let html_str = r#"<!DOCTYPE html> 4122<html><head><style> 4123body { margin: 0; } 4124.container { display: flex; width: 600px; justify-content: space-between; } 4125.item { width: 100px; height: 50px; } 4126</style></head> 4127<body> 4128<div class="container"> 4129 <div class="item">A</div> 4130 <div class="item">B</div> 4131 <div class="item">C</div> 4132</div> 4133</body></html>"#; 4134 let doc = we_html::parse_html(html_str); 4135 let tree = layout_doc(&doc); 4136 4137 let body = &tree.root.children[0]; 4138 let container = &body.children[0]; 4139 4140 // 3 items of 100px each = 300px, 300px free, between = 150 4141 // Positions: 0, 250, 500 4142 let a = &container.children[0]; 4143 let b = &container.children[1]; 4144 let c = &container.children[2]; 4145 4146 assert!( 4147 (a.rect.x - 0.0).abs() < 1.0, 4148 "first item x should be 0, got {}", 4149 a.rect.x 4150 ); 4151 assert!( 4152 (b.rect.x - 250.0).abs() < 1.0, 4153 "second item x should be 250, got {}", 4154 b.rect.x 4155 ); 4156 assert!( 4157 (c.rect.x - 500.0).abs() < 1.0, 4158 "third item x should be 500, got {}", 4159 c.rect.x 4160 ); 4161 } 4162 4163 #[test] 4164 fn flex_align_items_center() { 4165 let html_str = r#"<!DOCTYPE html> 4166<html><head><style> 4167body { margin: 0; } 4168.container { display: flex; width: 600px; height: 200px; align-items: center; } 4169.item { width: 100px; height: 50px; } 4170</style></head> 4171<body> 4172<div class="container"> 4173 <div class="item">A</div> 4174</div> 4175</body></html>"#; 4176 let doc = we_html::parse_html(html_str); 4177 let tree = layout_doc(&doc); 4178 4179 let body = &tree.root.children[0]; 4180 let container = &body.children[0]; 4181 let a = &container.children[0]; 4182 4183 // Container height=200, item height=50, centered at y = container.y + 75 4184 let expected_y = container.rect.y + 75.0; 4185 assert!( 4186 (a.rect.y - expected_y).abs() < 1.0, 4187 "item y should be {}, got {}", 4188 expected_y, 4189 a.rect.y 4190 ); 4191 } 4192 4193 #[test] 4194 fn flex_wrap_wraps_to_new_line() { 4195 let html_str = r#"<!DOCTYPE html> 4196<html><head><style> 4197body { margin: 0; } 4198.container { display: flex; flex-wrap: wrap; width: 250px; } 4199.item { width: 100px; height: 50px; } 4200</style></head> 4201<body> 4202<div class="container"> 4203 <div class="item">A</div> 4204 <div class="item">B</div> 4205 <div class="item">C</div> 4206</div> 4207</body></html>"#; 4208 let doc = we_html::parse_html(html_str); 4209 let tree = layout_doc(&doc); 4210 4211 let body = &tree.root.children[0]; 4212 let container = &body.children[0]; 4213 4214 // 250px container, 100px items: A and B fit on line 1, C wraps to line 2 4215 let a = &container.children[0]; 4216 let b = &container.children[1]; 4217 let c = &container.children[2]; 4218 4219 // A and B should be on the same row 4220 assert!((a.rect.y - b.rect.y).abs() < 1.0); 4221 // C should be on a different row (50px below) 4222 assert!( 4223 (c.rect.y - a.rect.y - 50.0).abs() < 1.0, 4224 "C should be 50px below A, got y diff {}", 4225 c.rect.y - a.rect.y 4226 ); 4227 } 4228 4229 #[test] 4230 fn flex_gap_adds_spacing() { 4231 let html_str = r#"<!DOCTYPE html> 4232<html><head><style> 4233body { margin: 0; } 4234.container { display: flex; width: 600px; gap: 20px; } 4235.item { width: 100px; height: 50px; } 4236</style></head> 4237<body> 4238<div class="container"> 4239 <div class="item">A</div> 4240 <div class="item">B</div> 4241 <div class="item">C</div> 4242</div> 4243</body></html>"#; 4244 let doc = we_html::parse_html(html_str); 4245 let tree = layout_doc(&doc); 4246 4247 let body = &tree.root.children[0]; 4248 let container = &body.children[0]; 4249 4250 let a = &container.children[0]; 4251 let b = &container.children[1]; 4252 let c = &container.children[2]; 4253 4254 // With gap: 20px, positions should be: 0, 120, 240 4255 assert!((a.rect.x - 0.0).abs() < 1.0); 4256 assert!( 4257 (b.rect.x - 120.0).abs() < 1.0, 4258 "B x should be 120, got {}", 4259 b.rect.x 4260 ); 4261 assert!( 4262 (c.rect.x - 240.0).abs() < 1.0, 4263 "C x should be 240, got {}", 4264 c.rect.x 4265 ); 4266 } 4267 4268 #[test] 4269 fn flex_order_changes_visual_order() { 4270 let html_str = r#"<!DOCTYPE html> 4271<html><head><style> 4272body { margin: 0; } 4273.container { display: flex; width: 600px; } 4274.a { width: 100px; height: 50px; order: 2; } 4275.b { width: 100px; height: 50px; order: 1; } 4276.c { width: 100px; height: 50px; order: 3; } 4277</style></head> 4278<body> 4279<div class="container"> 4280 <div class="a">A</div> 4281 <div class="b">B</div> 4282 <div class="c">C</div> 4283</div> 4284</body></html>"#; 4285 let doc = we_html::parse_html(html_str); 4286 let tree = layout_doc(&doc); 4287 4288 let body = &tree.root.children[0]; 4289 let container = &body.children[0]; 4290 4291 // DOM order: A(order:2), B(order:1), C(order:3) 4292 // Visual order: B(1), A(2), C(3) 4293 // B is at index 1 in DOM, A at 0, C at 2. 4294 // B (index 1) should be first visually (x ≈ 0) 4295 // A (index 0) should be second visually (x ≈ 100) 4296 // C (index 2) should be third visually (x ≈ 200) 4297 let a = &container.children[0]; // DOM index 0, order 2 4298 let b = &container.children[1]; // DOM index 1, order 1 4299 let c = &container.children[2]; // DOM index 2, order 3 4300 4301 assert!( 4302 b.rect.x < a.rect.x, 4303 "B (order:1) should be before A (order:2), B.x={} A.x={}", 4304 b.rect.x, 4305 a.rect.x 4306 ); 4307 assert!( 4308 a.rect.x < c.rect.x, 4309 "A (order:2) should be before C (order:3), A.x={} C.x={}", 4310 a.rect.x, 4311 c.rect.x 4312 ); 4313 } 4314 4315 #[test] 4316 fn flex_shrink_items() { 4317 let html_str = r#"<!DOCTYPE html> 4318<html><head><style> 4319body { margin: 0; } 4320.container { display: flex; width: 200px; } 4321.item { width: 100px; height: 50px; flex-shrink: 1; } 4322</style></head> 4323<body> 4324<div class="container"> 4325 <div class="item">A</div> 4326 <div class="item">B</div> 4327 <div class="item">C</div> 4328</div> 4329</body></html>"#; 4330 let doc = we_html::parse_html(html_str); 4331 let tree = layout_doc(&doc); 4332 4333 let body = &tree.root.children[0]; 4334 let container = &body.children[0]; 4335 4336 // 3 items * 100px = 300px in 200px container, shrink evenly 4337 // Each should be ~66.67px 4338 for (i, child) in container.children.iter().enumerate() { 4339 assert!( 4340 (child.rect.width - 200.0 / 3.0).abs() < 1.0, 4341 "item {} width should be ~66.67, got {}", 4342 i, 4343 child.rect.width 4344 ); 4345 } 4346 } 4347 4348 // ----------------------------------------------------------------------- 4349 // Absolute positioning tests 4350 // ----------------------------------------------------------------------- 4351 4352 /// Helper to create: <html><body>...content...</body></html> and return 4353 /// (doc, html_id, body_id). 4354 fn make_html_body(doc: &mut Document) -> (NodeId, NodeId, NodeId) { 4355 let root = doc.root(); 4356 let html = doc.create_element("html"); 4357 let body = doc.create_element("body"); 4358 doc.append_child(root, html); 4359 doc.append_child(html, body); 4360 (root, html, body) 4361 } 4362 4363 #[test] 4364 fn absolute_positioned_with_top_left() { 4365 let mut doc = Document::new(); 4366 let (_, _, body) = make_html_body(&mut doc); 4367 // <div style="position: relative; width: 400px; height: 300px;"> 4368 // <div style="position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;"></div> 4369 // </div> 4370 let container = doc.create_element("div"); 4371 let abs_child = doc.create_element("div"); 4372 doc.append_child(body, container); 4373 doc.append_child(container, abs_child); 4374 doc.set_attribute( 4375 container, 4376 "style", 4377 "position: relative; width: 400px; height: 300px;", 4378 ); 4379 doc.set_attribute( 4380 abs_child, 4381 "style", 4382 "position: absolute; top: 10px; left: 20px; width: 100px; height: 50px;", 4383 ); 4384 4385 let tree = layout_doc(&doc); 4386 let body_box = &tree.root.children[0]; 4387 let container_box = &body_box.children[0]; 4388 let abs_box = &container_box.children[0]; 4389 4390 assert_eq!(abs_box.position, Position::Absolute); 4391 assert_eq!(abs_box.rect.width, 100.0); 4392 assert_eq!(abs_box.rect.height, 50.0); 4393 4394 // The containing block is the container's padding box. 4395 // Container content starts at container_box.rect.x, container_box.rect.y. 4396 // Padding box x = container_box.rect.x - container_box.padding.left. 4397 let cb_x = container_box.rect.x - container_box.padding.left; 4398 let cb_y = container_box.rect.y - container_box.padding.top; 4399 4400 // abs_box content area x = cb_x + 20 (left) + 0 (margin) + 0 (border) + 0 (padding) 4401 assert!( 4402 (abs_box.rect.x - (cb_x + 20.0)).abs() < 0.01, 4403 "abs x should be cb_x + 20, got {} (cb_x={})", 4404 abs_box.rect.x, 4405 cb_x, 4406 ); 4407 assert!( 4408 (abs_box.rect.y - (cb_y + 10.0)).abs() < 0.01, 4409 "abs y should be cb_y + 10, got {} (cb_y={})", 4410 abs_box.rect.y, 4411 cb_y, 4412 ); 4413 } 4414 4415 #[test] 4416 fn absolute_does_not_affect_sibling_layout() { 4417 let mut doc = Document::new(); 4418 let (_, _, body) = make_html_body(&mut doc); 4419 // <div style="position: relative;"> 4420 // <div style="position: absolute; top: 0; left: 0; width: 100px; height: 100px;"></div> 4421 // <p>Normal text</p> 4422 // </div> 4423 let container = doc.create_element("div"); 4424 let abs_child = doc.create_element("div"); 4425 let p = doc.create_element("p"); 4426 let text = doc.create_text("Normal text"); 4427 doc.append_child(body, container); 4428 doc.append_child(container, abs_child); 4429 doc.append_child(container, p); 4430 doc.append_child(p, text); 4431 doc.set_attribute(container, "style", "position: relative;"); 4432 doc.set_attribute( 4433 abs_child, 4434 "style", 4435 "position: absolute; top: 0; left: 0; width: 100px; height: 100px;", 4436 ); 4437 4438 let tree = layout_doc(&doc); 4439 let body_box = &tree.root.children[0]; 4440 let container_box = &body_box.children[0]; 4441 4442 // The <p> should start at the top of the container (abs child doesn't 4443 // push it down). Find the first in-flow child. 4444 let p_box = container_box 4445 .children 4446 .iter() 4447 .find(|c| c.position != Position::Absolute && c.position != Position::Fixed) 4448 .expect("should have an in-flow child"); 4449 4450 // p's y should be at (or very near) the container's content y. 4451 let expected_y = container_box.rect.y; 4452 assert!( 4453 (p_box.rect.y - expected_y).abs() < 1.0, 4454 "p should start near container top: p.y={}, expected={}", 4455 p_box.rect.y, 4456 expected_y, 4457 ); 4458 } 4459 4460 #[test] 4461 fn fixed_positioned_relative_to_viewport() { 4462 let mut doc = Document::new(); 4463 let (_, _, body) = make_html_body(&mut doc); 4464 // <div style="position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;"></div> 4465 let fixed = doc.create_element("div"); 4466 doc.append_child(body, fixed); 4467 doc.set_attribute( 4468 fixed, 4469 "style", 4470 "position: fixed; top: 5px; left: 10px; width: 50px; height: 30px;", 4471 ); 4472 4473 let tree = layout_doc(&doc); 4474 let body_box = &tree.root.children[0]; 4475 let fixed_box = &body_box.children[0]; 4476 4477 assert_eq!(fixed_box.position, Position::Fixed); 4478 assert_eq!(fixed_box.rect.width, 50.0); 4479 assert_eq!(fixed_box.rect.height, 30.0); 4480 4481 // Fixed should be relative to viewport (0,0). 4482 assert!( 4483 (fixed_box.rect.x - 10.0).abs() < 0.01, 4484 "fixed x should be 10, got {}", 4485 fixed_box.rect.x, 4486 ); 4487 assert!( 4488 (fixed_box.rect.y - 5.0).abs() < 0.01, 4489 "fixed y should be 5, got {}", 4490 fixed_box.rect.y, 4491 ); 4492 } 4493 4494 #[test] 4495 fn z_index_ordering() { 4496 let mut doc = Document::new(); 4497 let (_, _, body) = make_html_body(&mut doc); 4498 // Create a positioned container with two abs children at different z-index. 4499 let container = doc.create_element("div"); 4500 let low = doc.create_element("div"); 4501 let high = doc.create_element("div"); 4502 doc.append_child(body, container); 4503 doc.append_child(container, low); 4504 doc.append_child(container, high); 4505 doc.set_attribute( 4506 container, 4507 "style", 4508 "position: relative; width: 200px; height: 200px;", 4509 ); 4510 doc.set_attribute( 4511 low, 4512 "style", 4513 "position: absolute; z-index: 1; top: 0; left: 0; width: 50px; height: 50px;", 4514 ); 4515 doc.set_attribute( 4516 high, 4517 "style", 4518 "position: absolute; z-index: 5; top: 0; left: 0; width: 50px; height: 50px;", 4519 ); 4520 4521 let tree = layout_doc(&doc); 4522 let body_box = &tree.root.children[0]; 4523 let container_box = &body_box.children[0]; 4524 4525 // Both should have correct z-index stored. 4526 let low_box = &container_box.children[0]; 4527 let high_box = &container_box.children[1]; 4528 assert_eq!(low_box.z_index, Some(1)); 4529 assert_eq!(high_box.z_index, Some(5)); 4530 } 4531 4532 #[test] 4533 fn absolute_stretching_left_right() { 4534 let mut doc = Document::new(); 4535 let (_, _, body) = make_html_body(&mut doc); 4536 // <div style="position: relative; width: 400px; height: 300px;"> 4537 // <div style="position: absolute; left: 10px; right: 20px; height: 50px;"></div> 4538 // </div> 4539 let container = doc.create_element("div"); 4540 let abs_child = doc.create_element("div"); 4541 doc.append_child(body, container); 4542 doc.append_child(container, abs_child); 4543 doc.set_attribute( 4544 container, 4545 "style", 4546 "position: relative; width: 400px; height: 300px;", 4547 ); 4548 doc.set_attribute( 4549 abs_child, 4550 "style", 4551 "position: absolute; left: 10px; right: 20px; height: 50px;", 4552 ); 4553 4554 let tree = layout_doc(&doc); 4555 let body_box = &tree.root.children[0]; 4556 let container_box = &body_box.children[0]; 4557 let abs_box = &container_box.children[0]; 4558 4559 // Width should be: container_width - left - right = 400 - 10 - 20 = 370 4560 assert!( 4561 (abs_box.rect.width - 370.0).abs() < 0.01, 4562 "stretched width should be 370, got {}", 4563 abs_box.rect.width, 4564 ); 4565 } 4566 4567 #[test] 4568 fn absolute_bottom_right_positioning() { 4569 let mut doc = Document::new(); 4570 let (_, _, body) = make_html_body(&mut doc); 4571 // <div style="position: relative; width: 400px; height: 300px;"> 4572 // <div style="position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;"></div> 4573 // </div> 4574 let container = doc.create_element("div"); 4575 let abs_child = doc.create_element("div"); 4576 doc.append_child(body, container); 4577 doc.append_child(container, abs_child); 4578 doc.set_attribute( 4579 container, 4580 "style", 4581 "position: relative; width: 400px; height: 300px;", 4582 ); 4583 doc.set_attribute( 4584 abs_child, 4585 "style", 4586 "position: absolute; bottom: 10px; right: 20px; width: 50px; height: 30px;", 4587 ); 4588 4589 let tree = layout_doc(&doc); 4590 let body_box = &tree.root.children[0]; 4591 let container_box = &body_box.children[0]; 4592 let abs_box = &container_box.children[0]; 4593 4594 let cb_x = container_box.rect.x - container_box.padding.left; 4595 let cb_y = container_box.rect.y - container_box.padding.top; 4596 let cb_w = 4597 container_box.rect.width + container_box.padding.left + container_box.padding.right; 4598 let cb_h = 4599 container_box.rect.height + container_box.padding.top + container_box.padding.bottom; 4600 4601 // x = cb_x + cb_w - right - width = cb_x + cb_w - 20 - 50 4602 let expected_x = cb_x + cb_w - 20.0 - 50.0; 4603 // y = cb_y + cb_h - bottom - height = cb_y + cb_h - 10 - 30 4604 let expected_y = cb_y + cb_h - 10.0 - 30.0; 4605 4606 assert!( 4607 (abs_box.rect.x - expected_x).abs() < 0.01, 4608 "abs x with right should be {}, got {}", 4609 expected_x, 4610 abs_box.rect.x, 4611 ); 4612 assert!( 4613 (abs_box.rect.y - expected_y).abs() < 0.01, 4614 "abs y with bottom should be {}, got {}", 4615 expected_y, 4616 abs_box.rect.y, 4617 ); 4618 } 4619}