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