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 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 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 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 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 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 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 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 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 if child.tag.as_deref() == Some("br") {
169 cursor_y += inline_line_height.max(node.style.line_height);
171
172 inline_line_height = 0.0;
174 inline_cursor_x = line_start_x;
175
176 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 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 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 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 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 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 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 if let NodeContent::Text(layout) = &node.content {
562 print!(" \x1b[35m(lines: {})\x1b[0m", layout.lines.len());
563 }
564
565 println!(); 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 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 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 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 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); 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 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); assert!((children[1].rect.width - 100.0).abs() < 1.0); assert!((children[2].rect.width - 250.0).abs() < 1.0); }
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); assert!((children[1].rect.width - 250.0).abs() < 1.0); }
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 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), make_cell(Display::TableCell, SizeValue::Auto),
908 ]);
909 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 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); }
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 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 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}