From a7956ead564a4be63fae5f83b6cc69807a2fed65 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Tue, 3 Mar 2026 18:51:01 +0100 Subject: [PATCH] Implement basic block layout engine Build layout trees from DOM documents with block-level element stacking, text word-wrapping, and hardcoded default styles for Phase 3. - LayoutBox tree with Block, Inline, TextRun, and Anonymous box types - Block layout: children stack vertically, take full parent width - Inline layout: collect text from inline children, word-wrap at container width - Default styles: body margin 8px, p margins 1em, h1-h6 font sizes - Anonymous block wrapping for mixed block/inline children - Line height 1.2em, whitespace collapsing - LayoutTree iterator for depth-first traversal - 17 unit tests covering all acceptance criteria Co-Authored-By: Claude Opus 4.6 --- crates/layout/src/lib.rs | 977 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 976 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2544187..6173ff3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1 +1,976 @@ -//! Box generation, block/inline/flex/grid/table layout. +//! Block layout engine: box generation, block/inline layout, and text wrapping. +//! +//! Builds a layout tree from a DOM document and positions block-level elements +//! vertically with text wrapping. Uses hardcoded default styles (no CSS yet). + +use we_dom::{Document, NodeData, NodeId}; +use we_text::font::Font; + +/// Edge sizes for box model (margin, padding, border). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct EdgeSizes { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +/// A positioned rectangle with content area dimensions. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// The type of layout box. +#[derive(Debug)] +pub enum BoxType { + /// Block-level box from an element. + Block(NodeId), + /// Inline-level box from an element. + Inline(NodeId), + /// A run of text from a text node. + TextRun { node: NodeId, text: String }, + /// Anonymous block wrapping inline content within a block container. + Anonymous, +} + +/// A single line of wrapped text. +#[derive(Debug, Clone, PartialEq)] +pub struct TextLine { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, +} + +/// A box in the layout tree with dimensions and child boxes. +#[derive(Debug)] +pub struct LayoutBox { + pub box_type: BoxType, + pub rect: Rect, + pub margin: EdgeSizes, + pub padding: EdgeSizes, + pub border: EdgeSizes, + pub children: Vec, + pub font_size: f32, + /// Wrapped text lines (populated for boxes with inline content). + pub lines: Vec, +} + +impl LayoutBox { + fn new(box_type: BoxType, font_size: f32) -> Self { + LayoutBox { + box_type, + rect: Rect::default(), + margin: EdgeSizes::default(), + padding: EdgeSizes::default(), + border: EdgeSizes::default(), + children: Vec::new(), + font_size, + lines: Vec::new(), + } + } + + /// Total height including margin, border, and padding. + pub fn margin_box_height(&self) -> f32 { + self.margin.top + + self.border.top + + self.padding.top + + self.rect.height + + self.padding.bottom + + self.border.bottom + + self.margin.bottom + } + + /// Iterate over all boxes in depth-first pre-order. + pub fn iter(&self) -> LayoutBoxIter<'_> { + LayoutBoxIter { stack: vec![self] } + } +} + +/// Depth-first pre-order iterator over layout boxes. +pub struct LayoutBoxIter<'a> { + stack: Vec<&'a LayoutBox>, +} + +impl<'a> Iterator for LayoutBoxIter<'a> { + type Item = &'a LayoutBox; + + fn next(&mut self) -> Option<&'a LayoutBox> { + let node = self.stack.pop()?; + // Push children in reverse so leftmost child is visited first. + for child in node.children.iter().rev() { + self.stack.push(child); + } + Some(node) + } +} + +/// The result of laying out a document. +#[derive(Debug)] +pub struct LayoutTree { + pub root: LayoutBox, + pub width: f32, + pub height: f32, +} + +impl LayoutTree { + /// Iterate over all layout boxes in depth-first pre-order. + pub fn iter(&self) -> LayoutBoxIter<'_> { + self.root.iter() + } +} + +// --------------------------------------------------------------------------- +// Display type classification +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq)] +enum DisplayType { + Block, + Inline, + None, +} + +fn display_type(tag: &str) -> DisplayType { + match tag { + "html" | "body" | "div" | "p" | "pre" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "ul" + | "ol" | "li" | "blockquote" | "section" | "article" | "nav" | "header" | "footer" + | "main" | "hr" => DisplayType::Block, + + "span" | "a" | "em" | "strong" | "b" | "i" | "u" | "code" | "small" | "sub" | "sup" + | "br" => DisplayType::Inline, + + "head" | "title" | "script" | "style" | "link" | "meta" => DisplayType::None, + + _ => DisplayType::Block, + } +} + +// --------------------------------------------------------------------------- +// Default styles (hardcoded for Phase 3) +// --------------------------------------------------------------------------- + +fn default_font_size(tag: &str, parent_size: f32) -> f32 { + match tag { + "h1" => parent_size * 2.0, + "h2" => parent_size * 1.5, + "h3" => parent_size * 1.17, + "h4" => parent_size, + "h5" => parent_size * 0.83, + "h6" => parent_size * 0.67, + _ => parent_size, + } +} + +fn default_margin(tag: &str, font_size: f32) -> EdgeSizes { + match tag { + "body" => EdgeSizes { + top: 8.0, + right: 8.0, + bottom: 8.0, + left: 8.0, + }, + "p" => EdgeSizes { + top: font_size, + bottom: font_size, + ..EdgeSizes::default() + }, + "h1" => EdgeSizes { + top: font_size * 0.67, + bottom: font_size * 0.67, + ..EdgeSizes::default() + }, + "h2" => EdgeSizes { + top: font_size * 0.83, + bottom: font_size * 0.83, + ..EdgeSizes::default() + }, + "h3" | "h4" => EdgeSizes { + top: font_size, + bottom: font_size, + ..EdgeSizes::default() + }, + "h5" | "h6" => EdgeSizes { + top: font_size * 1.67, + bottom: font_size * 1.67, + ..EdgeSizes::default() + }, + _ => EdgeSizes::default(), + } +} + +// --------------------------------------------------------------------------- +// Build layout tree from DOM +// --------------------------------------------------------------------------- + +fn build_box(doc: &Document, node: NodeId, parent_font_size: f32) -> Option { + match doc.node_data(node) { + NodeData::Document => { + let mut children = Vec::new(); + for child in doc.children(node) { + if let Some(child_box) = build_box(doc, child, parent_font_size) { + children.push(child_box); + } + } + // Unwrap single root element (typically ). + if children.len() == 1 { + children.into_iter().next() + } else if children.is_empty() { + None + } else { + let mut b = LayoutBox::new(BoxType::Anonymous, parent_font_size); + b.children = children; + Some(b) + } + } + NodeData::Element { tag_name, .. } => { + let dt = display_type(tag_name); + if dt == DisplayType::None { + return None; + } + + let font_size = default_font_size(tag_name, parent_font_size); + let margin = default_margin(tag_name, font_size); + + let mut children = Vec::new(); + for child in doc.children(node) { + if let Some(child_box) = build_box(doc, child, font_size) { + children.push(child_box); + } + } + + let box_type = match dt { + DisplayType::Block => BoxType::Block(node), + DisplayType::Inline => BoxType::Inline(node), + DisplayType::None => unreachable!(), + }; + + // For block containers, ensure children are uniformly block or inline. + if dt == DisplayType::Block { + children = normalize_children(children, font_size); + } + + let mut b = LayoutBox::new(box_type, font_size); + b.margin = margin; + b.children = children; + Some(b) + } + NodeData::Text { data } => { + let collapsed = collapse_whitespace(data); + if collapsed.is_empty() { + return None; + } + Some(LayoutBox::new( + BoxType::TextRun { + node, + text: collapsed, + }, + parent_font_size, + )) + } + NodeData::Comment { .. } => None, + } +} + +/// Collapse runs of whitespace to a single space. Preserves non-whitespace content. +fn collapse_whitespace(s: &str) -> String { + let mut result = String::new(); + let mut in_ws = false; + for ch in s.chars() { + if ch.is_whitespace() { + if !in_ws { + result.push(' '); + } + in_ws = true; + } else { + in_ws = false; + result.push(ch); + } + } + result +} + +/// If a block container has a mix of block-level and inline-level children, +/// wrap consecutive inline runs in anonymous block boxes. +fn normalize_children(children: Vec, font_size: f32) -> Vec { + if children.is_empty() { + return children; + } + + let has_block = children.iter().any(is_block_level); + if !has_block { + // All inline — parent will do inline layout directly. + return children; + } + + let has_inline = children.iter().any(|c| !is_block_level(c)); + if !has_inline { + // All block — no wrapping needed. + return children; + } + + // Mixed: wrap consecutive inline runs in anonymous blocks. + let mut result = Vec::new(); + let mut inline_group: Vec = Vec::new(); + + for child in children { + if is_block_level(&child) { + if !inline_group.is_empty() { + let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); + anon.children = std::mem::take(&mut inline_group); + result.push(anon); + } + result.push(child); + } else { + inline_group.push(child); + } + } + + if !inline_group.is_empty() { + let mut anon = LayoutBox::new(BoxType::Anonymous, font_size); + anon.children = inline_group; + result.push(anon); + } + + result +} + +fn is_block_level(b: &LayoutBox) -> bool { + matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) +} + +// --------------------------------------------------------------------------- +// Layout algorithm +// --------------------------------------------------------------------------- + +/// Position and size a layout box within `available_width` at position (`x`, `y`). +/// +/// `x` and `y` mark the top-left corner of the box's margin area. +fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: &Font) { + let content_x = x + b.margin.left + b.border.left + b.padding.left; + let content_y = y + b.margin.top + b.border.top + b.padding.top; + let content_width = (available_width + - b.margin.left + - b.margin.right + - b.border.left + - b.border.right + - b.padding.left + - b.padding.right) + .max(0.0); + + b.rect.x = content_x; + b.rect.y = content_y; + b.rect.width = content_width; + + match &b.box_type { + BoxType::Block(_) | BoxType::Anonymous => { + if has_block_children(b) { + layout_block_children(b, font); + } else { + layout_inline_children(b, font); + } + } + BoxType::TextRun { .. } | BoxType::Inline(_) => { + // Handled by the parent's inline layout. + } + } +} + +fn has_block_children(b: &LayoutBox) -> bool { + b.children.iter().any(is_block_level) +} + +/// Lay out block-level children: stack them vertically. +fn layout_block_children(parent: &mut LayoutBox, font: &Font) { + let content_x = parent.rect.x; + let content_width = parent.rect.width; + let mut cursor_y = parent.rect.y; + + for child in &mut parent.children { + compute_layout(child, content_x, cursor_y, content_width, font); + cursor_y += child.margin_box_height(); + } + + parent.rect.height = cursor_y - parent.rect.y; +} + +/// Lay out inline children: collect text, word-wrap, and compute height. +fn layout_inline_children(parent: &mut LayoutBox, font: &Font) { + let text = collect_inline_text(&parent.children); + if text.is_empty() { + parent.rect.height = 0.0; + return; + } + + let line_height = parent.font_size * 1.2; + let wrapped = wrap_text(&text, parent.rect.width, font, parent.font_size); + + let mut y = parent.rect.y; + let mut positioned = Vec::with_capacity(wrapped.len()); + for line in wrapped { + positioned.push(TextLine { + text: line.text, + x: parent.rect.x, + y, + width: line.width, + }); + y += line_height; + } + + parent.rect.height = positioned.len() as f32 * line_height; + parent.lines = positioned; +} + +/// Recursively collect all text from inline children. +fn collect_inline_text(children: &[LayoutBox]) -> String { + let mut result = String::new(); + collect_text_recursive(children, &mut result); + result +} + +fn collect_text_recursive(children: &[LayoutBox], result: &mut String) { + for child in children { + match &child.box_type { + BoxType::TextRun { text, .. } => { + result.push_str(text); + } + BoxType::Inline(_) => { + collect_text_recursive(&child.children, result); + } + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// Text measurement and word wrapping +// --------------------------------------------------------------------------- + +/// Measure the total advance width of a text string at the given font size. +fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { + let shaped = font.shape_text(text, font_size); + match shaped.last() { + Some(last) => last.x_offset + last.x_advance, + None => 0.0, + } +} + +/// Word-wrap text to fit within `max_width`. +fn wrap_text(text: &str, max_width: f32, font: &Font, font_size: f32) -> Vec { + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { + return Vec::new(); + } + + let space_width = measure_text_width(font, " ", font_size); + let mut lines = Vec::new(); + let mut line_text = String::new(); + let mut line_width: f32 = 0.0; + + for word in &words { + let word_width = measure_text_width(font, word, font_size); + + if line_text.is_empty() { + // First word on line — always accept. + line_text.push_str(word); + line_width = word_width; + } else if line_width + space_width + word_width <= max_width { + line_text.push(' '); + line_text.push_str(word); + line_width += space_width + word_width; + } else { + // Emit current line, start new one. + lines.push(TextLine { + text: line_text, + x: 0.0, + y: 0.0, + width: line_width, + }); + line_text = word.to_string(); + line_width = word_width; + } + } + + if !line_text.is_empty() { + lines.push(TextLine { + text: line_text, + x: 0.0, + y: 0.0, + width: line_width, + }); + } + + lines +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +const BASE_FONT_SIZE: f32 = 16.0; + +/// Build and lay out a DOM document. +/// +/// Returns a `LayoutTree` with positioned boxes ready for rendering. +pub fn layout( + document: &Document, + viewport_width: f32, + _viewport_height: f32, + font: &Font, +) -> LayoutTree { + let mut root = match build_box(document, document.root(), BASE_FONT_SIZE) { + Some(b) => b, + None => { + return LayoutTree { + root: LayoutBox::new(BoxType::Anonymous, BASE_FONT_SIZE), + width: viewport_width, + height: 0.0, + }; + } + }; + + compute_layout(&mut root, 0.0, 0.0, viewport_width, font); + + let height = root.margin_box_height(); + LayoutTree { + root, + width: viewport_width, + height, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use we_dom::Document; + + // Helper: load a system font for testing. + fn test_font() -> Font { + let paths = [ + "/System/Library/Fonts/Geneva.ttf", + "/System/Library/Fonts/Monaco.ttf", + ]; + for path in &paths { + let p = std::path::Path::new(path); + if p.exists() { + return Font::from_file(p).expect("failed to parse font"); + } + } + panic!("no test font found"); + } + + // Helper: build a simple DOM and lay it out. + fn layout_simple_html(html_element: NodeId, doc: &Document) -> LayoutTree { + let _ = html_element; // doc.root() already wraps it + let font = test_font(); + layout(doc, 800.0, 600.0, &font) + } + + #[test] + fn empty_document() { + let doc = Document::new(); + let font = test_font(); + let tree = layout(&doc, 800.0, 600.0, &font); + // Empty document should produce a minimal layout. + assert_eq!(tree.width, 800.0); + } + + #[test] + fn single_paragraph() { + // Build:

Hello world

+ let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("Hello world"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = layout_simple_html(html, &doc); + + // Root should be the html element box. + assert!(matches!(tree.root.box_type, BoxType::Block(_))); + + // Find the p box (html > body > p). + let body_box = &tree.root.children[0]; + assert!(matches!(body_box.box_type, BoxType::Block(_))); + + let p_box = &body_box.children[0]; + assert!(matches!(p_box.box_type, BoxType::Block(_))); + + // p should have text lines. + assert!(!p_box.lines.is_empty(), "p should have wrapped text lines"); + assert_eq!(p_box.lines[0].text, "Hello world"); + + // p should have vertical margins (1em = 16px default). + assert_eq!(p_box.margin.top, 16.0); + assert_eq!(p_box.margin.bottom, 16.0); + } + + #[test] + fn blocks_stack_vertically() { + //

First

Second

+ let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p1 = doc.create_element("p"); + let t1 = doc.create_text("First"); + let p2 = doc.create_element("p"); + let t2 = doc.create_text("Second"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p1); + doc.append_child(p1, t1); + doc.append_child(body, p2); + doc.append_child(p2, t2); + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + let first = &body_box.children[0]; + let second = &body_box.children[1]; + + // Second paragraph should be below the first. + assert!( + second.rect.y > first.rect.y, + "second p (y={}) should be below first p (y={})", + second.rect.y, + first.rect.y + ); + } + + #[test] + fn heading_larger_than_body() { + //

Title

Text

+ let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let h1 = doc.create_element("h1"); + let h1_text = doc.create_text("Title"); + let p = doc.create_element("p"); + let p_text = doc.create_text("Text"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, h1); + doc.append_child(h1, h1_text); + doc.append_child(body, p); + doc.append_child(p, p_text); + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + let h1_box = &body_box.children[0]; + let p_box = &body_box.children[1]; + + // h1 should have a larger font size (2em = 32px). + assert!( + h1_box.font_size > p_box.font_size, + "h1 font_size ({}) should be > p font_size ({})", + h1_box.font_size, + p_box.font_size + ); + assert_eq!(h1_box.font_size, 32.0); + + // h1 should take more vertical space. + assert!( + h1_box.rect.height > p_box.rect.height, + "h1 height ({}) should be > p height ({})", + h1_box.rect.height, + p_box.rect.height + ); + } + + #[test] + fn body_has_default_margin() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("Test"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + + assert_eq!(body_box.margin.top, 8.0); + assert_eq!(body_box.margin.right, 8.0); + assert_eq!(body_box.margin.bottom, 8.0); + assert_eq!(body_box.margin.left, 8.0); + + // Body content should be offset by 8px from html content edge. + assert_eq!(body_box.rect.x, 8.0); + assert_eq!(body_box.rect.y, 8.0); + } + + #[test] + fn text_wraps_at_container_width() { + // Use a narrow viewport to force wrapping. + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = + doc.create_text("The quick brown fox jumps over the lazy dog and more words to wrap"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let font = test_font(); + // Narrow viewport: 100px (minus body margin 8+8 = 84px content width). + let tree = layout(&doc, 100.0, 600.0, &font); + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + // With a 84px content width, long text should wrap to multiple lines. + assert!( + p_box.lines.len() > 1, + "text should wrap to multiple lines, got {} lines", + p_box.lines.len() + ); + } + + #[test] + fn layout_produces_positive_dimensions() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let div = doc.create_element("div"); + let text = doc.create_text("Content"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, div); + doc.append_child(div, text); + + let tree = layout_simple_html(html, &doc); + + // All boxes should have non-negative dimensions. + for b in tree.iter() { + assert!(b.rect.width >= 0.0, "width should be >= 0"); + assert!(b.rect.height >= 0.0, "height should be >= 0"); + } + + // Overall layout should have positive height. + assert!(tree.height > 0.0, "layout height should be > 0"); + } + + #[test] + fn head_is_hidden() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let head = doc.create_element("head"); + let title = doc.create_element("title"); + let title_text = doc.create_text("Page Title"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let p_text = doc.create_text("Visible"); + doc.append_child(root, html); + doc.append_child(html, head); + doc.append_child(head, title); + doc.append_child(title, title_text); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, p_text); + + let tree = layout_simple_html(html, &doc); + + // html should have one child (body), head is display:none. + assert_eq!( + tree.root.children.len(), + 1, + "html should have 1 child (body), head should be hidden" + ); + } + + #[test] + fn mixed_block_and_inline() { + //
Text

Block

More
+ let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let div = doc.create_element("div"); + let text1 = doc.create_text("Text"); + let p = doc.create_element("p"); + let p_text = doc.create_text("Block"); + let text2 = doc.create_text("More"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, div); + doc.append_child(div, text1); + doc.append_child(div, p); + doc.append_child(p, p_text); + doc.append_child(div, text2); + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + let div_box = &body_box.children[0]; + + // div should have 3 children: anonymous(Text), block(p), anonymous(More). + assert_eq!( + div_box.children.len(), + 3, + "div should have 3 children (anon, block, anon), got {}", + div_box.children.len() + ); + + assert!(matches!(div_box.children[0].box_type, BoxType::Anonymous)); + assert!(matches!(div_box.children[1].box_type, BoxType::Block(_))); + assert!(matches!(div_box.children[2].box_type, BoxType::Anonymous)); + } + + #[test] + fn inline_elements_contribute_text() { + //

Hello world!

+ let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let t1 = doc.create_text("Hello "); + let em = doc.create_element("em"); + let t2 = doc.create_text("world"); + let t3 = doc.create_text("!"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, t1); + doc.append_child(p, em); + doc.append_child(em, t2); + doc.append_child(p, t3); + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + // Text should be collected from inline children. + assert!(!p_box.lines.is_empty()); + assert_eq!(p_box.lines[0].text, "Hello world!"); + } + + #[test] + fn collapse_whitespace_works() { + assert_eq!(collapse_whitespace("hello world"), "hello world"); + assert_eq!(collapse_whitespace(" spaces "), " spaces "); + assert_eq!(collapse_whitespace("\n\ttabs\n"), " tabs "); + assert_eq!(collapse_whitespace("no-extra"), "no-extra"); + assert_eq!(collapse_whitespace(" "), " "); + } + + #[test] + fn display_type_classification() { + assert_eq!(display_type("div"), DisplayType::Block); + assert_eq!(display_type("p"), DisplayType::Block); + assert_eq!(display_type("h1"), DisplayType::Block); + assert_eq!(display_type("span"), DisplayType::Inline); + assert_eq!(display_type("a"), DisplayType::Inline); + assert_eq!(display_type("head"), DisplayType::None); + assert_eq!(display_type("script"), DisplayType::None); + assert_eq!(display_type("unknown-tag"), DisplayType::Block); + } + + #[test] + fn default_font_sizes() { + assert_eq!(default_font_size("h1", 16.0), 32.0); + assert_eq!(default_font_size("h2", 16.0), 24.0); + assert_eq!(default_font_size("p", 16.0), 16.0); + assert_eq!(default_font_size("div", 16.0), 16.0); + } + + #[test] + fn heading_margins() { + let m = default_margin("h1", 32.0); + let expected = 32.0 * 0.67; + assert!((m.top - expected).abs() < 0.01); + assert!((m.bottom - expected).abs() < 0.01); + } + + #[test] + fn layout_tree_iteration() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let p = doc.create_element("p"); + let text = doc.create_text("Test"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, p); + doc.append_child(p, text); + + let tree = layout_simple_html(html, &doc); + let count = tree.iter().count(); + assert!(count >= 3, "should have at least html, body, p boxes"); + } + + #[test] + fn content_width_respects_body_margin() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let div = doc.create_element("div"); + let text = doc.create_text("Content"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, div); + doc.append_child(div, text); + + let font = test_font(); + let tree = layout(&doc, 800.0, 600.0, &font); + let body_box = &tree.root.children[0]; + + // body content width = 800 - 8 - 8 = 784 + assert_eq!(body_box.rect.width, 784.0); + + // div inside body should also be 784px wide. + let div_box = &body_box.children[0]; + assert_eq!(div_box.rect.width, 784.0); + } + + #[test] + fn multiple_heading_levels() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + doc.append_child(root, html); + doc.append_child(html, body); + + let tags = ["h1", "h2", "h3"]; + for tag in &tags { + let h = doc.create_element(tag); + let t = doc.create_text(tag); + doc.append_child(body, h); + doc.append_child(h, t); + } + + let tree = layout_simple_html(html, &doc); + let body_box = &tree.root.children[0]; + + // h1 font_size > h2 font_size > h3 font_size + let h1 = &body_box.children[0]; + let h2 = &body_box.children[1]; + let h3 = &body_box.children[2]; + assert!(h1.font_size > h2.font_size); + assert!(h2.font_size > h3.font_size); + + // All should stack vertically. + assert!(h2.rect.y > h1.rect.y); + assert!(h3.rect.y > h2.rect.y); + } +} -- 2.51.2