From fa1afc9e01ad350bb63c7bcf50f662dac4432f7c Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Thu, 5 Mar 2026 21:23:04 +0100 Subject: [PATCH] Implement inline formatting context with per-fragment styling Replace the simplified inline text layout with a proper inline formatting context (IFC): Layout crate: - Flatten inline tree into items (words, spaces, breaks, inline starts/ends) - Word-wrap into line boxes respecting per-fragment font sizes - Handle
as forced line breaks - Apply text-align (left, center, right) via horizontal offset - Use computed line-height instead of hardcoded font_size * 1.2 - Inline box model: margin/padding/border offsets for inline elements - TextLine now carries per-fragment styling (font_size, color, text_decoration, background_color) instead of inheriting from parent - Store text_align and line_height on LayoutBox Render crate: - paint_text uses per-TextLine styling (color, font_size, text_decoration) - Render inline fragment backgrounds when not transparent - Underline uses per-fragment font_size for baseline calculation Tests: 7 new (per-fragment styling,
, text-align center/right, inline padding, font_size per fragment, line-height) Co-Authored-By: Claude Opus 4.6 --- crates/layout/src/lib.rs | 660 +++++++++++++++++++++++++++++++-------- crates/render/src/lib.rs | 35 ++- 2 files changed, 552 insertions(+), 143 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 65b171b..5e4d3f0 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1,12 +1,12 @@ //! Block layout engine: box generation, block/inline layout, and text wrapping. //! //! Builds a layout tree from a styled tree (DOM + computed styles) and positions -//! block-level elements vertically with text wrapping. +//! block-level elements vertically with proper inline formatting context. use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; use we_style::computed::{ - BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextDecoration, + BorderStyle, ComputedStyle, Display, LengthOrAuto, StyledNode, TextAlign, TextDecoration, }; use we_text::font::Font; @@ -41,13 +41,21 @@ pub enum BoxType { Anonymous, } -/// A single line of wrapped text. +/// A single positioned text fragment with its own styling. +/// +/// Multiple fragments can share the same y-coordinate when they are +/// on the same visual line (e.g. `

Hello world

` produces +/// two fragments at the same y). #[derive(Debug, Clone, PartialEq)] pub struct TextLine { pub text: String, pub x: f32, pub y: f32, pub width: f32, + pub font_size: f32, + pub color: Color, + pub text_decoration: TextDecoration, + pub background_color: Color, } /// A box in the layout tree with dimensions and child boxes. @@ -60,7 +68,7 @@ pub struct LayoutBox { pub border: EdgeSizes, pub children: Vec, pub font_size: f32, - /// Wrapped text lines (populated for boxes with inline content). + /// Positioned text fragments (populated for boxes with inline content). pub lines: Vec, /// Text color. pub color: Color, @@ -72,6 +80,10 @@ pub struct LayoutBox { pub border_styles: [BorderStyle; 4], /// Border colors (top, right, bottom, left). pub border_colors: [Color; 4], + /// Text alignment for this box's inline content. + pub text_align: TextAlign, + /// Computed line height in px. + pub line_height: f32, } impl LayoutBox { @@ -100,6 +112,8 @@ impl LayoutBox { style.border_bottom_color, style.border_left_color, ], + text_align: style.text_align, + line_height: style.line_height, } } @@ -130,7 +144,6 @@ impl<'a> Iterator for LayoutBoxIter<'a> { 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); } @@ -174,8 +187,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { match doc.node_data(node) { NodeData::Document => { - // Shouldn't reach here since resolve_styles produces element root, - // but handle gracefully. let mut children = Vec::new(); for child in &styled.children { if let Some(child_box) = build_box(child, doc) { @@ -193,7 +204,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { } } NodeData::Element { .. } => { - // display:none is already filtered by resolve_styles, but guard anyway. if style.display == Display::None { return None; } @@ -210,7 +220,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { bottom: style.padding_bottom, left: style.padding_left, }; - // Only apply border widths when border-style is not none. let border = EdgeSizes { top: if style.border_top_style != BorderStyle::None { style.border_top_width @@ -247,7 +256,6 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { Display::None => unreachable!(), }; - // For block containers, ensure children are uniformly block or inline. if style.display == Display::Block { children = normalize_children(children, style); } @@ -276,7 +284,7 @@ fn build_box(styled: &StyledNode, doc: &Document) -> Option { } } -/// Collapse runs of whitespace to a single space. Preserves non-whitespace content. +/// Collapse runs of whitespace to a single space. fn collapse_whitespace(s: &str) -> String { let mut result = String::new(); let mut in_ws = false; @@ -303,17 +311,14 @@ fn normalize_children(children: Vec, parent_style: &ComputedStyle) -> 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(); @@ -348,9 +353,14 @@ fn is_block_level(b: &LayoutBox) -> bool { // --------------------------------------------------------------------------- /// 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) { +fn compute_layout( + b: &mut LayoutBox, + x: f32, + y: f32, + available_width: f32, + font: &Font, + doc: &Document, +) { 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 @@ -369,9 +379,9 @@ fn compute_layout(b: &mut LayoutBox, x: f32, y: f32, available_width: f32, font: match &b.box_type { BoxType::Block(_) | BoxType::Anonymous => { if has_block_children(b) { - layout_block_children(b, font); + layout_block_children(b, font, doc); } else { - layout_inline_children(b, font); + layout_inline_children(b, font, doc); } } BoxType::TextRun { .. } | BoxType::Inline(_) => { @@ -385,126 +395,308 @@ fn has_block_children(b: &LayoutBox) -> bool { } /// Lay out block-level children: stack them vertically. -fn layout_block_children(parent: &mut LayoutBox, font: &Font) { +fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 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); + compute_layout(child, content_x, cursor_y, content_width, font, doc); 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; - } +// --------------------------------------------------------------------------- +// Inline formatting context +// --------------------------------------------------------------------------- - parent.rect.height = positioned.len() as f32 * line_height; - parent.lines = positioned; +/// An inline item produced by flattening the inline tree. +enum InlineItemKind { + /// A word of text with associated styling. + Word { + text: String, + font_size: f32, + color: Color, + text_decoration: TextDecoration, + background_color: Color, + }, + /// Whitespace between words. + Space { font_size: f32 }, + /// Forced line break (`
`). + ForcedBreak, + /// Start of an inline box (for margin/padding/border tracking). + InlineStart { + margin_left: f32, + padding_left: f32, + border_left: f32, + }, + /// End of an inline box. + InlineEnd { + margin_right: f32, + padding_right: f32, + border_right: f32, + }, } -/// 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 +/// A pending fragment on the current line. +struct PendingFragment { + text: String, + x: f32, + width: f32, + font_size: f32, + color: Color, + text_decoration: TextDecoration, + background_color: Color, } -fn collect_text_recursive(children: &[LayoutBox], result: &mut String) { +/// Flatten the inline children tree into a sequence of items. +fn flatten_inline_tree(children: &[LayoutBox], doc: &Document, items: &mut Vec) { for child in children { match &child.box_type { BoxType::TextRun { text, .. } => { - result.push_str(text); + let words = split_into_words(text); + for segment in words { + match segment { + WordSegment::Word(w) => { + items.push(InlineItemKind::Word { + text: w, + font_size: child.font_size, + color: child.color, + text_decoration: child.text_decoration, + background_color: child.background_color, + }); + } + WordSegment::Space => { + items.push(InlineItemKind::Space { + font_size: child.font_size, + }); + } + } + } } - BoxType::Inline(_) => { - collect_text_recursive(&child.children, result); + BoxType::Inline(node_id) => { + if let NodeData::Element { tag_name, .. } = doc.node_data(*node_id) { + if tag_name == "br" { + items.push(InlineItemKind::ForcedBreak); + continue; + } + } + + items.push(InlineItemKind::InlineStart { + margin_left: child.margin.left, + padding_left: child.padding.left, + border_left: child.border.left, + }); + + flatten_inline_tree(&child.children, doc, items); + + items.push(InlineItemKind::InlineEnd { + margin_right: child.margin.right, + padding_right: child.padding.right, + border_right: child.border.right, + }); } _ => {} } } } -// --------------------------------------------------------------------------- -// Text measurement and word wrapping -// --------------------------------------------------------------------------- +enum WordSegment { + Word(String), + Space, +} -/// 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, +/// Split text into alternating words and spaces. +fn split_into_words(text: &str) -> Vec { + let mut segments = Vec::new(); + let mut current_word = String::new(); + + for ch in text.chars() { + if ch == ' ' { + if !current_word.is_empty() { + segments.push(WordSegment::Word(std::mem::take(&mut current_word))); + } + segments.push(WordSegment::Space); + } else { + current_word.push(ch); + } } + + if !current_word.is_empty() { + segments.push(WordSegment::Word(current_word)); + } + + segments } -/// 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(); +/// Lay out inline children using a proper inline formatting context. +fn layout_inline_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { + let available_width = parent.rect.width; + let text_align = parent.text_align; + let line_height = parent.line_height; + + let mut items = Vec::new(); + flatten_inline_tree(&parent.children, doc, &mut items); + + if items.is_empty() { + parent.rect.height = 0.0; + return; } - 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, + // Process items into line boxes. + let mut all_lines: Vec> = Vec::new(); + let mut current_line: Vec = Vec::new(); + let mut cursor_x: f32 = 0.0; + + for item in &items { + match item { + InlineItemKind::Word { + text, + font_size, + color, + text_decoration, + background_color, + } => { + let word_width = measure_text_width(font, text, *font_size); + + // If this word doesn't fit and the line isn't empty, break. + if cursor_x > 0.0 && cursor_x + word_width > available_width { + all_lines.push(std::mem::take(&mut current_line)); + cursor_x = 0.0; + } + + current_line.push(PendingFragment { + text: text.clone(), + x: cursor_x, + width: word_width, + font_size: *font_size, + color: *color, + text_decoration: *text_decoration, + background_color: *background_color, + }); + cursor_x += word_width; + } + InlineItemKind::Space { font_size } => { + // Only add space if we have content on the line. + if !current_line.is_empty() { + let space_width = measure_text_width(font, " ", *font_size); + if cursor_x + space_width <= available_width { + cursor_x += space_width; + } + } + } + InlineItemKind::ForcedBreak => { + all_lines.push(std::mem::take(&mut current_line)); + cursor_x = 0.0; + } + InlineItemKind::InlineStart { + margin_left, + padding_left, + border_left, + } => { + cursor_x += margin_left + padding_left + border_left; + } + InlineItemKind::InlineEnd { + margin_right, + padding_right, + border_right, + } => { + cursor_x += margin_right + padding_right + border_right; + } + } + } + + // Flush the last line. + if !current_line.is_empty() { + all_lines.push(current_line); + } + + if all_lines.is_empty() { + parent.rect.height = 0.0; + return; + } + + // Position lines vertically and apply text-align. + let mut text_lines = Vec::new(); + let mut y = parent.rect.y; + let num_lines = all_lines.len(); + + for (line_idx, line_fragments) in all_lines.iter().enumerate() { + if line_fragments.is_empty() { + y += line_height; + continue; + } + + // Compute line width from last fragment. + let line_width = match line_fragments.last() { + Some(last) => last.x + last.width, + None => 0.0, + }; + + // Compute text-align offset. + let is_last_line = line_idx == num_lines - 1; + let align_offset = + compute_align_offset(text_align, available_width, line_width, is_last_line); + + for frag in line_fragments { + text_lines.push(TextLine { + text: frag.text.clone(), + x: parent.rect.x + frag.x + align_offset, + y, + width: frag.width, + font_size: frag.font_size, + color: frag.color, + text_decoration: frag.text_decoration, + background_color: frag.background_color, }); - line_text = word.to_string(); - line_width = word_width; } + + y += line_height; } - if !line_text.is_empty() { - lines.push(TextLine { - text: line_text, - x: 0.0, - y: 0.0, - width: line_width, - }); + parent.rect.height = num_lines as f32 * line_height; + parent.lines = text_lines; +} + +/// Compute the horizontal offset for text alignment. +fn compute_align_offset( + align: TextAlign, + available_width: f32, + line_width: f32, + is_last_line: bool, +) -> f32 { + let extra_space = (available_width - line_width).max(0.0); + match align { + TextAlign::Left => 0.0, + TextAlign::Center => extra_space / 2.0, + TextAlign::Right => extra_space, + TextAlign::Justify => { + // Don't justify the last line (CSS spec behavior). + if is_last_line { + 0.0 + } else { + // For justify, we shift the whole line by 0 — the actual distribution + // of space between words would need per-word spacing. For now, treat + // as left-aligned; full justify support is a future enhancement. + 0.0 + } + } } +} + +// --------------------------------------------------------------------------- +// Text measurement +// --------------------------------------------------------------------------- - lines +/// 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, + } } // --------------------------------------------------------------------------- @@ -532,7 +724,7 @@ pub fn layout( } }; - compute_layout(&mut root, 0.0, 0.0, viewport_width, font); + compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc); let height = root.margin_box_height(); LayoutTree { @@ -548,7 +740,6 @@ mod tests { use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; - // Helper: load a system font for testing. fn test_font() -> Font { let paths = [ "/System/Library/Fonts/Geneva.ttf", @@ -563,7 +754,6 @@ mod tests { panic!("no test font found"); } - // Helper: build a DOM, resolve styles, and lay it out. fn layout_doc(doc: &Document) -> LayoutTree { let font = test_font(); let sheets = extract_stylesheets(doc); @@ -581,7 +771,6 @@ mod tests { let tree = layout(&styled, &doc, 800.0, 600.0, &font); assert_eq!(tree.width, 800.0); } - // Empty document with no styled root is fine — just produces empty layout. } #[test] @@ -599,21 +788,30 @@ mod tests { let tree = layout_doc(&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"); + assert!(!p_box.lines.is_empty(), "p should have text fragments"); + + // Collect all text on the first visual line. + let first_y = p_box.lines[0].y; + let line_text: String = p_box + .lines + .iter() + .filter(|l| (l.y - first_y).abs() < 0.01) + .map(|l| l.text.as_str()) + .collect::>() + .join(" "); + assert!( + line_text.contains("Hello") && line_text.contains("world"), + "line should contain Hello and world, got: {line_text}" + ); - // p should have vertical margins (1em = 16px default from UA stylesheet). assert_eq!(p_box.margin.top, 16.0); assert_eq!(p_box.margin.bottom, 16.0); } @@ -640,7 +838,6 @@ mod tests { 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={})", @@ -671,7 +868,6 @@ mod tests { 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 ({})", @@ -680,7 +876,6 @@ mod tests { ); 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 ({})", @@ -710,7 +905,6 @@ mod tests { 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); } @@ -732,16 +926,19 @@ mod tests { let font = test_font(); let sheets = extract_stylesheets(&doc); let styled = resolve_styles(&doc, &sheets).unwrap(); - // Narrow viewport: 100px (minus body margin 8+8 = 84px content width). let tree = layout(&styled, &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. + // Count distinct y-positions to count visual lines. + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + assert!( - p_box.lines.len() > 1, - "text should wrap to multiple lines, got {} lines", - p_box.lines.len() + ys.len() > 1, + "text should wrap to multiple lines, got {} visual lines", + ys.len() ); } @@ -760,13 +957,11 @@ mod tests { let tree = layout_doc(&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"); } @@ -791,7 +986,6 @@ mod tests { let tree = layout_doc(&doc); - // html should have one child (body), head is display:none. assert_eq!( tree.root.children.len(), 1, @@ -801,7 +995,6 @@ mod tests { #[test] fn mixed_block_and_inline() { - //
Text

Block

More
let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -823,7 +1016,6 @@ mod tests { 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, @@ -838,7 +1030,6 @@ mod tests { #[test] fn inline_elements_contribute_text() { - //

Hello world!

let mut doc = Document::new(); let root = doc.root(); let html = doc.create_element("html"); @@ -860,9 +1051,20 @@ mod tests { 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!"); + + let first_y = p_box.lines[0].y; + let line_texts: Vec<&str> = p_box + .lines + .iter() + .filter(|l| (l.y - first_y).abs() < 0.01) + .map(|l| l.text.as_str()) + .collect(); + let combined = line_texts.join(""); + assert!( + combined.contains("Hello") && combined.contains("world") && combined.contains("!"), + "line should contain all text, got: {combined}" + ); } #[test] @@ -893,10 +1095,8 @@ mod tests { let tree = layout(&styled, &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); } @@ -921,14 +1121,12 @@ mod tests { let tree = layout_doc(&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); } @@ -953,7 +1151,6 @@ mod tests { #[test] fn css_style_affects_layout() { - // Test that CSS styles from +

Hello world

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let colors: Vec = p_box.lines.iter().map(|l| l.color).collect(); + assert!( + colors.iter().any(|c| *c == Color::rgb(0, 0, 0)), + "should have black text" + ); + assert!( + colors.iter().any(|c| *c == Color::rgb(255, 0, 0)), + "should have red text from " + ); + } + + #[test] + fn br_element_forces_line_break() { + let html_str = r#" + +

Line one
Line two

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + + assert!( + ys.len() >= 2, + "
should produce 2 visual lines, got {}", + ys.len() + ); + } + + #[test] + fn text_align_center() { + let html_str = r#" + + +

Hi

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!(!p_box.lines.is_empty()); + let first = &p_box.lines[0]; + // Center-aligned: text should be noticeably offset from content x. + assert!( + first.x > p_box.rect.x + 10.0, + "center-aligned text x ({}) should be offset from content x ({})", + first.x, + p_box.rect.x + ); + } + + #[test] + fn text_align_right() { + let html_str = r#" + + +

Hi

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!(!p_box.lines.is_empty()); + let first = &p_box.lines[0]; + let right_edge = p_box.rect.x + p_box.rect.width; + assert!( + (first.x + first.width - right_edge).abs() < 1.0, + "right-aligned text end ({}) should be near right edge ({})", + first.x + first.width, + right_edge + ); + } + + #[test] + fn inline_padding_offsets_text() { + let html_str = r#" + + +

ABC

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + // Should have at least 3 fragments: A, B, C + assert!( + p_box.lines.len() >= 3, + "should have fragments for A, B, C, got {}", + p_box.lines.len() + ); + + // B should be offset by the span's padding. + let a_frag = &p_box.lines[0]; + let b_frag = &p_box.lines[1]; + let gap = b_frag.x - (a_frag.x + a_frag.width); + // Gap should include the 20px padding-left from the span. + assert!( + gap >= 19.0, + "gap between A and B ({gap}) should include span padding-left (20px)" + ); + } + + #[test] + fn text_fragments_have_correct_font_size() { + let html_str = r#" + +

Big

Small

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let h1_box = &body_box.children[0]; + let p_box = &body_box.children[1]; + + assert!(!h1_box.lines.is_empty()); + assert!(!p_box.lines.is_empty()); + assert_eq!(h1_box.lines[0].font_size, 32.0); + assert_eq!(p_box.lines[0].font_size, 16.0); + } + + #[test] + fn line_height_from_computed_style() { + let html_str = r#" + + +

Line one Line two Line three

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets).unwrap(); + // Narrow viewport to force wrapping. + let tree = layout(&styled, &doc, 100.0, 600.0, &font); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + let mut ys: Vec = p_box.lines.iter().map(|l| l.y).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup_by(|a, b| (*a - *b).abs() < 0.01); + + if ys.len() >= 2 { + let gap = ys[1] - ys[0]; + assert!( + (gap - 30.0).abs() < 1.0, + "line spacing ({gap}) should be ~30px from line-height" + ); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3e89149..d0b1619 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,7 @@ pub enum PaintCommand { height: f32, color: Color, }, - /// Draw a glyph bitmap at a position with a text color. + /// Draw a text fragment at a position with styling. DrawGlyphs { line: TextLine, font_size: f32, @@ -124,20 +124,21 @@ fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList) { } fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { - let color = layout_box.color; - let underline = layout_box.text_decoration == TextDecoration::Underline; - for line in &layout_box.lines { + // Use per-fragment styling from the TextLine. + let color = line.color; + let font_size = line.font_size; + list.push(PaintCommand::DrawGlyphs { line: line.clone(), - font_size: layout_box.font_size, + font_size, color, }); // Draw underline as a 1px line below the baseline. - if underline && line.width > 0.0 { - let baseline_y = line.y + layout_box.font_size; - let underline_y = baseline_y + 2.0; // 2px below baseline + if line.text_decoration == TextDecoration::Underline && line.width > 0.0 { + let baseline_y = line.y + font_size; + let underline_y = baseline_y + 2.0; list.push(PaintCommand::FillRect { x: line.x, y: underline_y, @@ -146,6 +147,24 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList) { color, }); } + + // Draw inline background if not transparent. + if line.background_color.a > 0 && line.width > 0.0 { + // Insert background before the text (painter's order). + // We add it at the end for simplicity; a real implementation + // would insert before the DrawGlyphs. + let bg_idx = list.len() - 1; // Index of the DrawGlyphs we just pushed. + list.insert( + bg_idx, + PaintCommand::FillRect { + x: line.x, + y: line.y, + width: line.width, + height: font_size * 1.2, + color: line.background_color, + }, + ); + } } } -- 2.51.2