Skip to main content

oxidize_html/
layout.rs

1use crate::{
2    Display, LayoutNode, NodeContent, Rect, SizeValue, StyledNode, TextLayout,
3    image::{ImageSource, parse_source, source_dimensions},
4};
5
6#[derive(Default, Debug)]
7pub struct LayoutEngine;
8
9impl LayoutEngine {
10    /// Takes a Root [`StyledNode`] and the available width for layout and computes the layout tree, returning the root [`LayoutNode`].
11    pub fn compute(&mut self, root: &StyledNode, available_width: f32, debug: bool) -> LayoutNode {
12        let (_, node) = layout_node(root, 0.0, 0.0, available_width);
13        if debug {
14            println!("Debug Layout:");
15            println!();
16            debug_layout_tree(&node, 0);
17        }
18        node
19    }
20}
21
22fn layout_node(node: &StyledNode, x: f32, y: f32, parent_width: f32) -> (f32, LayoutNode) {
23    // Handle display: none nodes by creating a zero-sized box.
24    if node.style.display == Display::None {
25        let layout = LayoutNode {
26            node_id: node.node_id,
27            rect: Rect {
28                x,
29                y,
30                width: 0.0,
31                height: 0.0,
32            },
33            style: node.style.clone(),
34            content: NodeContent::Box,
35            bullet_origin: None,
36            children: Vec::new(),
37            tag: node.tag.clone(),
38        };
39        return (0.0, layout);
40    }
41
42    let margin = node.style.margin;
43    let padding = node.style.padding;
44    let content_x = x + margin.left + padding.left;
45    let top = y + margin.top;
46    let mut cursor_y = top + padding.top;
47
48    let width = resolve_width(node.style.width, parent_width).max(0.0);
49    let content_width = (width - padding.left - padding.right).max(0.0);
50
51    let mut children = Vec::new();
52
53    // TABLE ROW LOGIC
54    if is_table_row(node) {
55        let (row_children, row_height) = layout_table_row(content_x, cursor_y, content_width, node);
56        children.extend(row_children);
57        cursor_y += row_height;
58    }
59    // TABLE GROUP PASSTHROUGH (thead, tbody)
60    else if matches!(node.tag.as_deref(), Some("thead" | "tbody" | "tfoot")) {
61        for child in &node.children {
62            let (height, child_layout) = layout_node(child, content_x, cursor_y, content_width);
63            cursor_y += height;
64            children.push(child_layout);
65        }
66    } else {
67        // Standard block/inline logic
68        let (new_children, new_cursor_y) =
69            layout_children(node, content_x, content_width, cursor_y);
70
71        children.extend(new_children);
72        cursor_y = new_cursor_y;
73    }
74
75    let mut own_content = NodeContent::Box;
76    let mut intrinsic_height = 0.0;
77
78    // ISSUE 1 FIX: Handling intrinsic height for Text/Image/Hr
79    if let Some(text) = node.text.as_deref() {
80        let layout = layout_text(
81            text,
82            node.style.font_size,
83            node.style.line_height,
84            content_width,
85        );
86        intrinsic_height = layout.lines.len() as f32 * layout.line_height;
87        own_content = NodeContent::Text(layout);
88    } else if node.tag.as_deref() == Some("img") {
89        let source = node
90            .attrs
91            .get("src")
92            .map(|s| parse_source(s))
93            .unwrap_or(ImageSource::Invalid);
94        let intrinsic = source_dimensions(&source).map(|(w, h)| (w as f32, h as f32));
95        let (image_w, image_h) = resolve_image_size(
96            node.style.width,
97            node.style.height,
98            content_width.max(1.0),
99            intrinsic,
100        );
101        intrinsic_height = image_h;
102        own_content = NodeContent::Image {
103            source,
104            width: image_w,
105            height: image_h,
106        };
107    } else if node.tag.as_deref() == Some("hr") {
108        intrinsic_height = 1.0;
109        own_content = NodeContent::Hr;
110    }
111
112    let children_height = (cursor_y - (top + padding.top)).max(0.0);
113    // Combine children heights with the node's own intrinsic height (text, etc.)
114    let content_height = children_height.max(intrinsic_height);
115
116    let box_height = match node.style.height {
117        SizeValue::Px(px) => px,
118        _ => content_height + padding.top + padding.bottom,
119    };
120
121    let space_consumed = margin.top + box_height + margin.bottom;
122
123    let rect = Rect {
124        x: x + margin.left,
125        y: top,
126        width,
127        height: box_height,
128    };
129
130    let out = LayoutNode {
131        node_id: node.node_id,
132        rect,
133        style: node.style.clone(),
134        content: own_content,
135        bullet_origin: if node.style.display == Display::ListItem {
136            Some(crate::Point {
137                x: rect.x - 16.0,
138                y: rect.y,
139            })
140        } else {
141            None
142        },
143        children,
144        tag: node.tag.clone(),
145    };
146
147    (space_consumed, out)
148}
149
150fn layout_children(
151    node: &StyledNode,
152    content_x: f32,
153    content_width: f32,
154    mut cursor_y: f32,
155) -> (Vec<LayoutNode>, f32) {
156    // STANDARD BLOCK/INLINE LOGIC
157    let line_start_x = content_x;
158    let line_limit_x = line_start_x + content_width.max(1.0);
159    let mut inline_cursor_x = line_start_x;
160    let mut inline_line_height: f32 = 0.0;
161    let mut in_inline_run = false;
162    let mut children = Vec::new();
163    for child in &node.children {
164        if is_inline_node(child) {
165            in_inline_run = true;
166
167            // FIXED <br> LOGIC:
168            if child.tag.as_deref() == Some("br") {
169                // 1. End the current line and advance the cursor
170                cursor_y += inline_line_height.max(node.style.line_height);
171
172                // 2. RESET the line height so the NEXT line starts fresh
173                inline_line_height = 0.0;
174                inline_cursor_x = line_start_x;
175
176                // 3. Add the <br> to the children list so it shows in debug
177                let (_, _, br_layout) =
178                    layout_inline_node(child, line_start_x, cursor_y - node.style.line_height, 1.0);
179                children.push(br_layout);
180                continue;
181            }
182
183            let (mut cw, mut ch, mut cl) = layout_inline_node(
184                child,
185                inline_cursor_x,
186                cursor_y,
187                (line_limit_x - line_start_x).max(1.0),
188            );
189
190            // Handle wrapping
191            if inline_cursor_x > line_start_x && inline_cursor_x + cw > line_limit_x {
192                cursor_y += inline_line_height;
193                inline_cursor_x = line_start_x;
194                inline_line_height = 0.0;
195                let (nw, nh, nl) = layout_inline_node(
196                    child,
197                    inline_cursor_x,
198                    cursor_y,
199                    (line_limit_x - line_start_x).max(1.0),
200                );
201                cw = nw;
202                ch = nh;
203                cl = nl;
204            }
205
206            inline_cursor_x += cw;
207            inline_line_height = inline_line_height.max(ch);
208            children.push(cl);
209        } else {
210            if in_inline_run {
211                cursor_y += inline_line_height;
212                inline_cursor_x = line_start_x;
213                inline_line_height = 0.0;
214                in_inline_run = false;
215            }
216
217            let (height, child_layout) =
218                layout_node(child, content_x, cursor_y, content_width.max(1.0));
219            if height > 0.0 || child_layout.tag.is_some() {
220                cursor_y += height;
221                children.push(child_layout);
222            }
223        }
224    }
225    if in_inline_run {
226        cursor_y += inline_line_height;
227    }
228    (children, cursor_y)
229}
230
231fn layout_inline_node(
232    node: &StyledNode,
233    x: f32,
234    y: f32,
235    line_max_width: f32,
236) -> (f32, f32, LayoutNode) {
237    let margin = node.style.margin;
238    let padding = node.style.padding;
239    let content_x = x + margin.left + padding.left;
240    let content_y = y + margin.top + padding.top;
241    let max_width = line_max_width.max(1.0);
242
243    let mut own_content = NodeContent::Box;
244    let mut intrinsic_width = 0.0;
245    let mut intrinsic_height = 0.0;
246
247    if let Some(text) = node.text.as_deref() {
248        let has_leading_space = text.starts_with(char::is_whitespace);
249        let layout = layout_text(
250            text,
251            node.style.font_size,
252            node.style.line_height,
253            max_width,
254        );
255        let char_width = (node.style.font_size * 0.55).max(1.0);
256        intrinsic_width = layout
257            .lines
258            .iter()
259            .map(|line| line.chars().count() as f32 * char_width)
260            .fold(0.0, f32::max)
261            .max(char_width);
262        if has_leading_space {
263            intrinsic_width += char_width;
264        }
265        intrinsic_height = layout.lines.len() as f32 * layout.line_height;
266        own_content = NodeContent::Text(layout);
267    } else if node.tag.as_deref() == Some("img") {
268        let source = node
269            .attrs
270            .get("src")
271            .map(|s| parse_source(s))
272            .unwrap_or(ImageSource::Invalid);
273        let intrinsic = source_dimensions(&source).map(|(w, h)| (w as f32, h as f32));
274        let (w, h) = resolve_image_size(node.style.width, node.style.height, max_width, intrinsic);
275        intrinsic_width = w;
276        intrinsic_height = h;
277        own_content = NodeContent::Image {
278            source,
279            width: w,
280            height: h,
281        };
282    } else if node.tag.as_deref() == Some("hr") {
283        intrinsic_width = max_width;
284        intrinsic_height = 1.0;
285        own_content = NodeContent::Hr;
286    } else if node.tag.as_deref() == Some("br") {
287        // FIX: A <br> should occupy exactly one line of height
288        intrinsic_height = node.style.line_height;
289        own_content = NodeContent::Box;
290    }
291
292    let mut children = Vec::new();
293    let mut child_x = content_x;
294    let mut child_y = content_y;
295    let line_start_x = content_x;
296    let line_limit_x = line_start_x + max_width;
297    let mut line_height = 0.0;
298    let mut content_used_width = intrinsic_width;
299
300    for child in &node.children {
301        if is_inline_node(child) {
302            let (mut cw, mut ch, mut cl) = layout_inline_node(child, child_x, child_y, max_width);
303            if child_x > line_start_x && child_x + cw > line_limit_x {
304                child_y += line_height;
305                child_x = line_start_x;
306                line_height = 0.0;
307                let (nw, nh, nl) = layout_inline_node(child, child_x, child_y, max_width);
308                cw = nw;
309                ch = nh;
310                cl = nl;
311            }
312            child_x += cw;
313            line_height = line_height.max(ch);
314            content_used_width = content_used_width.max(child_x - line_start_x);
315            children.push(cl);
316        } else {
317            if line_height > 0.0 {
318                child_y += line_height;
319                child_x = line_start_x;
320                line_height = 0.0;
321            }
322            let (bh, bl) = layout_node(child, line_start_x, child_y, max_width);
323            child_y += bh;
324            content_used_width = content_used_width.max(bl.rect.width);
325            children.push(bl);
326        }
327    }
328    if line_height > 0.0 {
329        child_y += line_height;
330    }
331
332    let children_height = (child_y - content_y).max(0.0);
333    let content_height = intrinsic_height.max(children_height);
334
335    let resolved_width = match node.style.width {
336        SizeValue::Px(px) => px,
337        SizeValue::Percent(pct) => max_width * (pct / 100.0),
338        SizeValue::Auto => content_used_width,
339    };
340    let is_text_node = node.tag.is_none() && node.text.is_some();
341
342    let width = if is_text_node {
343        content_used_width
344    } else {
345        resolved_width + padding.left + padding.right + margin.left + margin.right
346    };
347
348    let height = if is_text_node {
349        content_height
350    } else {
351        match node.style.height {
352            SizeValue::Px(px) => px + margin.top + margin.bottom,
353            _ => content_height + padding.top + padding.bottom + margin.top + margin.bottom,
354        }
355    };
356    let rect = Rect {
357        x: if is_text_node { x } else { x + margin.left },
358        y: if is_text_node { y } else { y + margin.top },
359        width: width.max(0.0),
360        height: height.max(0.0),
361    };
362
363    let out = LayoutNode {
364        node_id: node.node_id,
365        rect,
366        style: node.style.clone(),
367        content: own_content,
368        bullet_origin: if node.style.display == Display::ListItem {
369            Some(crate::Point {
370                x: x + margin.left - 16.0,
371                y: y + margin.top,
372            })
373        } else {
374            None
375        },
376        children,
377        tag: node.tag.clone(),
378    };
379    (out.rect.width, out.rect.height, out)
380}
381
382fn is_inline_display(display: Display) -> bool {
383    matches!(display, Display::Inline | Display::InlineBlock)
384}
385
386fn is_inline_node(node: &StyledNode) -> bool {
387    is_inline_display(node.style.display) || (node.text.is_some() && node.tag.is_none())
388}
389
390fn is_table_row(node: &StyledNode) -> bool {
391    node.tag.as_deref() == Some("tr") || node.style.display == Display::TableRow
392}
393
394fn layout_table_row(
395    content_x: f32,
396    cursor_y: f32,
397    content_width: f32,
398    node: &StyledNode,
399) -> (Vec<LayoutNode>, f32) {
400    let mut cursor_x = content_x;
401    let mut row_height: f32 = 0.0;
402
403    let mut children = Vec::new();
404    // Count non-empty/visible children to divide space fairly
405    let visible_children: Vec<&StyledNode> = node
406        .children
407        .iter()
408        .filter(|c| c.style.display != Display::None)
409        .collect();
410    let cell_count = visible_children.len();
411
412    for (i, child) in visible_children.iter().enumerate() {
413        let child_parent_width = match child.style.width {
414            SizeValue::Px(px) => px.max(1.0),
415            SizeValue::Percent(pct) => (content_width * (pct / 100.0)).max(1.0),
416            SizeValue::Auto => {
417                if cell_count == 3 {
418                    let weights = [0.65, 0.10, 0.25];
419                    (content_width * weights[i]).max(1.0)
420                } else if cell_count == 2 {
421                    let weights = [0.75, 0.25];
422                    (content_width * weights[i]).max(1.0)
423                } else {
424                    (content_width / cell_count as f32).max(1.0)
425                }
426            }
427        };
428
429        let mut resolved_child = (*child).clone();
430        resolved_child.style.width = SizeValue::Px(child_parent_width);
431
432        let (height, child_layout) =
433            layout_node(&resolved_child, cursor_x, cursor_y, child_parent_width);
434        cursor_x += child_layout.rect.width.max(0.0);
435        row_height = row_height.max(height.max(child_layout.rect.height));
436        children.push(child_layout);
437    }
438
439    // Apply row_height to all cells (Second pass)
440    for child_layout in &mut children {
441        child_layout.rect.height = row_height;
442    }
443
444    (children, row_height)
445}
446
447#[allow(dead_code)]
448fn shift_layout_x(node: &mut LayoutNode, delta: f32) {
449    node.rect.x += delta;
450    if let Some(mut bullet) = node.bullet_origin {
451        bullet.x += delta;
452        node.bullet_origin = Some(bullet);
453    }
454    for child in &mut node.children {
455        shift_layout_x(child, delta);
456    }
457}
458
459fn resolve_width(size: SizeValue, parent_width: f32) -> f32 {
460    match size {
461        SizeValue::Px(px) => px,
462        SizeValue::Percent(p) => parent_width * (p / 100.0),
463        SizeValue::Auto => parent_width,
464    }
465}
466
467fn resolve_image_size(
468    width: SizeValue,
469    height: SizeValue,
470    max_width: f32,
471    intrinsic: Option<(f32, f32)>,
472) -> (f32, f32) {
473    let explicit_w = match width {
474        SizeValue::Px(px) => Some(px),
475        SizeValue::Percent(p) => Some(max_width * (p / 100.0)),
476        SizeValue::Auto => None,
477    };
478    let explicit_h = match height {
479        SizeValue::Px(px) => Some(px),
480        SizeValue::Percent(_) | SizeValue::Auto => None,
481    };
482
483    match (explicit_w, explicit_h, intrinsic) {
484        (Some(w), Some(h), _) => (w.max(1.0), h.max(1.0)),
485        (Some(w), None, Some((iw, ih))) if iw > 0.0 => (w.max(1.0), (w * ih / iw).max(1.0)),
486        (None, Some(h), Some((iw, ih))) if ih > 0.0 => ((h * iw / ih).max(1.0), h.max(1.0)),
487        (None, None, Some((iw, ih))) if iw > 0.0 => {
488            let w = iw.min(max_width).max(1.0);
489            let h = (w * ih / iw).max(1.0);
490            (w, h)
491        }
492        (Some(w), None, None) => (w.max(1.0), 24.0),
493        (None, Some(h), None) => (max_width.min(320.0).max(1.0), h.max(1.0)),
494        (None, None, None) => (max_width.min(320.0).max(1.0), 180.0),
495        _ => (max_width.min(320.0).max(1.0), 180.0),
496    }
497}
498
499fn layout_text(text: &str, font_size: f32, line_height: f32, max_width: f32) -> TextLayout {
500    let char_width = (font_size * 0.55).max(1.0);
501    let max_chars = (max_width / char_width).floor().max(1.0) as usize;
502    let mut lines = Vec::new();
503    let mut current = String::new();
504
505    for word in text.split_whitespace() {
506        if current.is_empty() {
507            current.push_str(word);
508            continue;
509        }
510        if current.len() + 1 + word.len() <= max_chars {
511            current.push(' ');
512            current.push_str(word);
513        } else {
514            lines.push(current);
515            current = word.to_string();
516        }
517    }
518    if !current.is_empty() {
519        lines.push(current);
520    }
521    if lines.is_empty() {
522        lines.push(String::new());
523    }
524
525    TextLayout {
526        lines,
527        line_height: if line_height > 0.0 {
528            line_height
529        } else {
530            font_size * 1.2
531        },
532        font_size,
533    }
534}
535fn debug_layout_tree(node: &LayoutNode, indent: usize) {
536    let indent_str = "  ".repeat(indent);
537
538    // 1. Differentiate between Tags and Text/Images
539    let label = if let Some(tag) = &node.tag {
540        format!("[<{}>]", tag)
541    } else {
542        match &node.content {
543            NodeContent::Text(layout) => {
544                let text_snippet: String = layout.lines.join(" ").chars().take(20).collect();
545                format!("\"{}...\"", text_snippet.escape_debug())
546            }
547            NodeContent::Image { source, .. } => format!("[<img> {:?}]", source),
548            NodeContent::Hr => "[<hr>]".to_string(),
549            NodeContent::Box => "[<box>]".to_string(),
550        }
551    };
552
553    // 2. Print Geometry in a readable format
554    // Using green for geometry to make it pop against the labels
555    print!(
556        "{}{:<25} \x1b[32mpos:({:>4.1}, {:>4.1}) size:[{:>4.1} x {:>4.1}]\x1b[0m",
557        indent_str, label, node.rect.x, node.rect.y, node.rect.width, node.rect.height
558    );
559
560    // 3. Add specific content indicators
561    if let NodeContent::Text(layout) = &node.content {
562        print!(" \x1b[35m(lines: {})\x1b[0m", layout.lines.len());
563    }
564
565    println!(); // End line
566
567    // 4. Recurse
568    for child in &node.children {
569        debug_layout_tree(child, indent + 1);
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::{ComputedStyle, HtmlRenderer, LayoutNode, NodeContent};
577    fn find_first_text(node: &LayoutNode) -> Option<&TextLayout> {
578        if let NodeContent::Text(layout) = &node.content {
579            return Some(layout);
580        }
581        for child in &node.children {
582            if let Some(found) = find_first_text(child) {
583                return Some(found);
584            }
585        }
586        None
587    }
588
589    fn collect_text_positions(node: &LayoutNode, out: &mut Vec<(String, f32, f32)>) {
590        if let NodeContent::Text(layout) = &node.content {
591            let text = layout.lines.join(" ");
592            out.push((text, node.rect.x, node.rect.y));
593        }
594        for child in &node.children {
595            collect_text_positions(child, out);
596        }
597    }
598
599    #[test]
600    fn wraps_text_into_multiple_lines() {
601        let html = "<div style='width:120px'>This is a long line of text for wrapping</div>";
602        let mut renderer = HtmlRenderer::default();
603        let mut style = renderer.style_tree(html);
604        crate::table::normalize_tables(&mut style, 120.0);
605        let mut engine = LayoutEngine;
606        let layout = engine.compute(&style, 120.0, false);
607        let text = find_first_text(&layout).expect("text");
608        assert!(text.lines.len() > 1);
609    }
610
611    #[test]
612    fn inline_children_wrap_left_to_right() {
613        let html = "<div style='width:120px'><span>aaaaaa</span><span>bbbbbb</span><span>cccccc</span></div>";
614        let mut renderer = HtmlRenderer::default();
615        let mut style = renderer.style_tree(html);
616        crate::table::normalize_tables(&mut style, 120.0);
617        let mut engine = LayoutEngine;
618        let layout = engine.compute(&style, 120.0, false);
619        let mut texts = Vec::new();
620        collect_text_positions(&layout, &mut texts);
621
622        let a = texts
623            .iter()
624            .find(|(t, _, _)| t.contains("aaaaaa"))
625            .expect("text a");
626        let b = texts
627            .iter()
628            .find(|(t, _, _)| t.contains("bbbbbb"))
629            .expect("text b");
630        let c = texts
631            .iter()
632            .find(|(t, _, _)| t.contains("cccccc"))
633            .expect("text c");
634
635        assert!(b.1 >= a.1 || b.2 > a.2);
636        assert!(c.2 >= b.2);
637    }
638
639    #[test]
640    fn table_row_cells_layout_horizontally() {
641        let html = r#"<table width="600"><tr><td width="200">A</td><td width="300">B</td><td width="100">C</td></tr></table>"#;
642        let mut renderer = HtmlRenderer::default();
643        let mut style = renderer.style_tree(html);
644        crate::table::normalize_tables(&mut style, 600.0);
645        let mut engine = LayoutEngine;
646        let layout = engine.compute(&style, 600.0, false);
647
648        let mut cells = Vec::new();
649        collect_cells(&layout, &mut cells);
650        assert_eq!(cells.len(), 3);
651        assert!(cells[1].rect.x > cells[0].rect.x);
652        assert!(cells[2].rect.x > cells[1].rect.x);
653        assert!((cells[1].rect.x - cells[0].rect.x - cells[0].rect.width).abs() < 1.0);
654        assert!((cells[2].rect.x - cells[1].rect.x - cells[1].rect.width).abs() < 1.0);
655    }
656
657    #[test]
658    fn colspan_cell_advances_row_cursor_by_resolved_width() {
659        let html = r#"
660            <table width="520">
661              <tr>
662                <td colspan="2" width="400" align="right">Subtotal</td>
663                <td width="120" align="right">$249.96</td>
664              </tr>
665            </table>
666        "#;
667        let mut renderer = HtmlRenderer::default();
668        let mut style = renderer.style_tree(html);
669        crate::table::normalize_tables(&mut style, 520.0);
670        let mut engine = LayoutEngine;
671        let layout = engine.compute(&style, 520.0, false);
672
673        let mut rows = Vec::new();
674        collect_rows(&layout, &mut rows);
675        let row = rows.into_iter().next().expect("row");
676        let cells: Vec<&LayoutNode> = row
677            .children
678            .iter()
679            .filter(|n| matches!(n.style.display, Display::TableCell))
680            .collect();
681        assert_eq!(cells.len(), 2);
682        assert!((cells[1].rect.x - (cells[0].rect.x + 400.0)).abs() < 1.0);
683    }
684
685    fn collect_rows<'a>(node: &'a LayoutNode, out: &mut Vec<&'a LayoutNode>) {
686        if matches!(node.style.display, Display::TableRow) {
687            out.push(node);
688            return;
689        }
690        for child in &node.children {
691            collect_rows(child, out);
692        }
693    }
694
695    fn collect_cells<'a>(node: &'a LayoutNode, out: &mut Vec<&'a LayoutNode>) {
696        if matches!(node.style.display, Display::TableCell)
697            && matches!(node.content, NodeContent::Box)
698        {
699            out.push(node);
700        }
701        for child in &node.children {
702            collect_cells(child, out);
703        }
704    }
705
706    #[test]
707    fn explicit_width_and_height() {
708        let (w, h) = resolve_image_size(
709            SizeValue::Px(200.0),
710            SizeValue::Px(100.0),
711            500.0,
712            Some((400.0, 300.0)),
713        );
714        assert_eq!(w, 200.0);
715        assert_eq!(h, 100.0);
716    }
717
718    #[test]
719    fn explicit_width_scales_height_from_intrinsic() {
720        // 200px widely, intrinsic is 400x300 (4:3), so height should be 150
721        let (w, h) = resolve_image_size(
722            SizeValue::Px(200.0),
723            SizeValue::Auto,
724            500.0,
725            Some((400.0, 300.0)),
726        );
727        assert_eq!(w, 200.0);
728        assert_eq!(h, 150.0);
729    }
730
731    #[test]
732    fn explicit_height_scales_width_from_intrinsic() {
733        // 150px tall, intrinsic is 400x300 (4:3), so width should be 200
734        let (w, h) = resolve_image_size(
735            SizeValue::Auto,
736            SizeValue::Px(150.0),
737            500.0,
738            Some((400.0, 300.0)),
739        );
740        assert_eq!(w, 200.0);
741        assert_eq!(h, 150.0);
742    }
743
744    #[test]
745    fn auto_size_uses_intrinsic_clamped_to_max_width() {
746        // intrinsic is 400x300, max_width is 200, so w=200, h=150
747        let (w, h) = resolve_image_size(
748            SizeValue::Auto,
749            SizeValue::Auto,
750            200.0,
751            Some((400.0, 300.0)),
752        );
753        assert_eq!(w, 200.0);
754        assert_eq!(h, 150.0);
755    }
756
757    #[test]
758    fn auto_size_intrinsic_smaller_than_max_width() {
759        // intrinsic 100x50, max_width 500, so w=100, h=50
760        let (w, h) =
761            resolve_image_size(SizeValue::Auto, SizeValue::Auto, 500.0, Some((100.0, 50.0)));
762        assert_eq!(w, 100.0);
763        assert_eq!(h, 50.0);
764    }
765
766    #[test]
767    fn explicit_width_no_intrinsic_uses_fallback_height() {
768        let (w, h) = resolve_image_size(SizeValue::Px(300.0), SizeValue::Auto, 500.0, None);
769        assert_eq!(w, 300.0);
770        assert_eq!(h, 24.0);
771    }
772
773    #[test]
774    fn explicit_height_no_intrinsic_uses_clamped_max_width() {
775        let (w, h) = resolve_image_size(SizeValue::Auto, SizeValue::Px(80.0), 500.0, None);
776        assert_eq!(w, 320.0); // min(500, 320)
777        assert_eq!(h, 80.0);
778    }
779
780    #[test]
781    fn no_size_no_intrinsic_uses_fallbacks() {
782        let (w, h) = resolve_image_size(SizeValue::Auto, SizeValue::Auto, 500.0, None);
783        assert_eq!(w, 320.0);
784        assert_eq!(h, 180.0);
785    }
786
787    #[test]
788    fn percent_width_resolved_against_max_width() {
789        // 50% of 400 = 200, height auto with intrinsic 400x200 -> h=100
790        let (w, h) = resolve_image_size(
791            SizeValue::Percent(50.0),
792            SizeValue::Auto,
793            400.0,
794            Some((400.0, 200.0)),
795        );
796        assert_eq!(w, 200.0);
797        assert_eq!(h, 100.0);
798    }
799
800    #[test]
801    fn min_size_clamp_prevents_zero() {
802        let (w, h) = resolve_image_size(SizeValue::Px(0.0), SizeValue::Px(0.0), 500.0, None);
803        assert_eq!(w, 1.0);
804        assert_eq!(h, 1.0);
805    }
806    fn make_cell(display: Display, width: SizeValue) -> StyledNode {
807        StyledNode {
808            node_id: 0,
809            tag: Some("td".to_string()),
810            attrs: Default::default(),
811            text: None,
812            style: ComputedStyle {
813                display,
814                width,
815                ..ComputedStyle::default()
816            },
817            children: Vec::new(),
818        }
819    }
820
821    fn make_row(cells: Vec<StyledNode>) -> StyledNode {
822        StyledNode {
823            node_id: 0,
824            tag: Some("tr".to_string()),
825            attrs: Default::default(),
826            text: None,
827            style: ComputedStyle {
828                display: Display::TableRow,
829                ..ComputedStyle::default()
830            },
831            children: cells,
832        }
833    }
834
835    #[test]
836    fn three_auto_cells_use_email_weights() {
837        let row = make_row(vec![
838            make_cell(Display::TableCell, SizeValue::Auto),
839            make_cell(Display::TableCell, SizeValue::Auto),
840            make_cell(Display::TableCell, SizeValue::Auto),
841        ]);
842        let (children, _) = layout_table_row(0.0, 0.0, 1000.0, &row);
843        assert_eq!(children.len(), 3);
844        assert!((children[0].rect.width - 650.0).abs() < 1.0); // 0.65 * 1000
845        assert!((children[1].rect.width - 100.0).abs() < 1.0); // 0.10 * 1000
846        assert!((children[2].rect.width - 250.0).abs() < 1.0); // 0.25 * 1000
847    }
848
849    #[test]
850    fn two_auto_cells_use_email_weights() {
851        let row = make_row(vec![
852            make_cell(Display::TableCell, SizeValue::Auto),
853            make_cell(Display::TableCell, SizeValue::Auto),
854        ]);
855        let (children, _) = layout_table_row(0.0, 0.0, 1000.0, &row);
856        assert_eq!(children.len(), 2);
857        assert!((children[0].rect.width - 750.0).abs() < 1.0); // 0.75 * 1000
858        assert!((children[1].rect.width - 250.0).abs() < 1.0); // 0.25 * 1000
859    }
860
861    #[test]
862    fn four_auto_cells_divide_equally() {
863        let row = make_row(vec![
864            make_cell(Display::TableCell, SizeValue::Auto),
865            make_cell(Display::TableCell, SizeValue::Auto),
866            make_cell(Display::TableCell, SizeValue::Auto),
867            make_cell(Display::TableCell, SizeValue::Auto),
868        ]);
869        let (children, _) = layout_table_row(0.0, 0.0, 1000.0, &row);
870        assert_eq!(children.len(), 4);
871        for child in &children {
872            assert!((child.rect.width - 250.0).abs() < 1.0);
873        }
874    }
875
876    #[test]
877    fn px_width_cells_use_explicit_width() {
878        let row = make_row(vec![
879            make_cell(Display::TableCell, SizeValue::Px(200.0)),
880            make_cell(Display::TableCell, SizeValue::Px(400.0)),
881        ]);
882        let (children, _) = layout_table_row(0.0, 0.0, 1000.0, &row);
883        assert!((children[0].rect.width - 200.0).abs() < 1.0);
884        assert!((children[1].rect.width - 400.0).abs() < 1.0);
885    }
886
887    #[test]
888    fn percent_width_cells_resolve_against_content_width() {
889        let row = make_row(vec![
890            make_cell(Display::TableCell, SizeValue::Percent(25.0)),
891            make_cell(Display::TableCell, SizeValue::Percent(75.0)),
892        ]);
893        let (children, _) = layout_table_row(0.0, 0.0, 800.0, &row);
894        // percent resolves in layout_table_row to child_parent_width,
895        // then layout_node re-resolves style.width=Percent against that value
896        // so just verify relative sizing is correct
897        assert!(children[0].rect.width < children[1].rect.width);
898        let total = children[0].rect.width + children[1].rect.width;
899        assert!((total - 800.0).abs() < 1.0);
900    }
901
902    #[test]
903    fn display_none_cells_are_excluded() {
904        let row = make_row(vec![
905            make_cell(Display::TableCell, SizeValue::Auto),
906            make_cell(Display::None, SizeValue::Auto), // should be skipped
907            make_cell(Display::TableCell, SizeValue::Auto),
908        ]);
909        // Only 2 visible cells, so should use 2-cell weights
910        let (children, _) = layout_table_row(0.0, 0.0, 1000.0, &row);
911        assert_eq!(children.len(), 2);
912        assert!((children[0].rect.width - 750.0).abs() < 1.0);
913        assert!((children[1].rect.width - 250.0).abs() < 1.0);
914    }
915
916    #[test]
917    fn cells_share_same_row_height() {
918        let row = make_row(vec![
919            make_cell(Display::TableCell, SizeValue::Auto),
920            make_cell(Display::TableCell, SizeValue::Auto),
921            make_cell(Display::TableCell, SizeValue::Auto),
922        ]);
923        let (children, row_height) = layout_table_row(0.0, 0.0, 900.0, &row);
924        for child in &children {
925            assert_eq!(child.rect.height, row_height);
926        }
927    }
928
929    #[test]
930    fn cells_positioned_left_to_right() {
931        let row = make_row(vec![
932            make_cell(Display::TableCell, SizeValue::Px(100.0)),
933            make_cell(Display::TableCell, SizeValue::Px(200.0)),
934            make_cell(Display::TableCell, SizeValue::Px(300.0)),
935        ]);
936        let (children, _) = layout_table_row(0.0, 0.0, 600.0, &row);
937        assert!((children[0].rect.x - 0.0).abs() < 1.0);
938        assert!((children[1].rect.x - 100.0).abs() < 1.0);
939        assert!((children[2].rect.x - 300.0).abs() < 1.0);
940    }
941
942    #[test]
943    fn content_x_offset_applied() {
944        let row = make_row(vec![make_cell(Display::TableCell, SizeValue::Px(100.0))]);
945        let (children, _) = layout_table_row(50.0, 0.0, 600.0, &row);
946        assert!((children[0].rect.x - 50.0).abs() < 1.0);
947    }
948
949    #[test]
950    fn cursor_y_applied_to_cells() {
951        let row = make_row(vec![make_cell(Display::TableCell, SizeValue::Auto)]);
952        let (children, _) = layout_table_row(0.0, 100.0, 600.0, &row);
953        assert!((children[0].rect.y - 100.0).abs() < 1.0);
954    }
955    #[test]
956    fn single_short_word_fits_on_one_line() {
957        let layout = layout_text("Hello", 16.0, 19.2, 200.0);
958        assert_eq!(layout.lines.len(), 1);
959        assert_eq!(layout.lines[0], "Hello");
960    }
961
962    #[test]
963    fn multiple_words_fit_on_one_line() {
964        let layout = layout_text("Hello world", 16.0, 19.2, 200.0);
965        assert_eq!(layout.lines.len(), 1);
966        assert_eq!(layout.lines[0], "Hello world");
967    }
968
969    #[test]
970    fn long_text_wraps_to_multiple_lines() {
971        // char_width = 16 * 0.55 = 8.8, max_chars = floor(100 / 8.8) = 11
972        // "Hello world" = 11 chars, fits. "Hello world foo" = 15, doesn't.
973        let layout = layout_text("Hello world foo", 16.0, 19.2, 100.0);
974        assert!(layout.lines.len() > 1);
975        assert_eq!(layout.lines[0], "Hello world");
976        assert_eq!(layout.lines[1], "foo");
977    }
978
979    #[test]
980    fn empty_string_produces_one_empty_line() {
981        let layout = layout_text("", 16.0, 19.2, 200.0);
982        assert_eq!(layout.lines.len(), 1);
983        assert_eq!(layout.lines[0], "");
984    }
985
986    #[test]
987    fn whitespace_only_produces_one_empty_line() {
988        let layout = layout_text("   \n\t  ", 16.0, 19.2, 200.0);
989        assert_eq!(layout.lines.len(), 1);
990        assert_eq!(layout.lines[0], "");
991    }
992
993    #[test]
994    fn explicit_line_height_used_when_positive() {
995        let layout = layout_text("Hello", 16.0, 24.0, 200.0);
996        assert_eq!(layout.line_height, 24.0);
997    }
998
999    #[test]
1000    fn zero_line_height_falls_back_to_font_size_times_1_2() {
1001        let layout = layout_text("Hello", 16.0, 0.0, 200.0);
1002        assert!((layout.line_height - 19.2).abs() < 0.01); // 16 * 1.2
1003    }
1004
1005    #[test]
1006    fn negative_line_height_falls_back_to_font_size_times_1_2() {
1007        let layout = layout_text("Hello", 16.0, -1.0, 200.0);
1008        assert!((layout.line_height - 19.2).abs() < 0.01);
1009    }
1010
1011    #[test]
1012    fn font_size_stored_correctly() {
1013        let layout = layout_text("Hello", 24.0, 19.2, 200.0);
1014        assert_eq!(layout.font_size, 24.0);
1015    }
1016
1017    #[test]
1018    fn very_narrow_width_puts_each_word_on_its_own_line() {
1019        // char_width = 16 * 0.55 = 8.8, max_chars = floor(8.8 / 8.8) = 1
1020        // each word is longer than 1 char, so each goes on its own line
1021        let layout = layout_text("a b c", 16.0, 19.2, 8.8);
1022        assert_eq!(layout.lines, vec!["a", "b", "c"]);
1023    }
1024
1025    #[test]
1026    fn leading_and_trailing_whitespace_is_ignored() {
1027        let layout = layout_text("  Hello world  ", 16.0, 19.2, 200.0);
1028        assert_eq!(layout.lines.len(), 1);
1029        assert_eq!(layout.lines[0], "Hello world");
1030    }
1031
1032    #[test]
1033    fn newlines_in_text_are_treated_as_whitespace() {
1034        let layout = layout_text("Hello\nworld", 16.0, 19.2, 200.0);
1035        assert_eq!(layout.lines.len(), 1);
1036        assert_eq!(layout.lines[0], "Hello world");
1037    }
1038
1039    #[test]
1040    fn single_very_long_word_goes_on_its_own_line() {
1041        // A word longer than max_chars still gets placed, just alone on its line
1042        let layout = layout_text("superlongwordthatexceedsmaxwidth", 16.0, 19.2, 50.0);
1043        assert_eq!(layout.lines.len(), 1);
1044        assert_eq!(layout.lines[0], "superlongwordthatexceedsmaxwidth");
1045    }
1046}