diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 76ae5a8..3c928ac 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -41,6 +41,12 @@ pub struct DomLayout { /// per node. `None` means unclipped. Does not include the node's own /// overflow (that only clips its children, not itself). pub clip_rects: HashMap>, + /// Per-word geometry for text nodes: a text DOM node lays out as one + /// taffy leaf per word (see `build_text_words`), each wrapping + /// independently within its container, so its rendered content is a list + /// of (box, word text) pairs rather than a single box, unlike every other + /// node kind. Keyed by the text node's `NodeId`, in layout order. + pub text_runs: HashMap>, } /// Walk the tree top-down accumulating the clip rect imposed by ancestor @@ -180,6 +186,7 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { let mut taffy_tree: TaffyTree = TaffyTree::new(); let mut id_map: HashMap = HashMap::new(); + let mut words: HashMap = HashMap::new(); // The document node itself is not an element; lay out from the first // element descendant (the root). @@ -189,6 +196,7 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { .find(|id| tree.get_node(*id).map(|n| n.is_element()).unwrap_or(false)); let mut rects = HashMap::new(); + let mut text_runs = HashMap::new(); if let Some(root_id) = root { // Top-down inheritance of the properties CSS inherits by default. #[derive(Clone, Default)] @@ -211,7 +219,7 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { resolve_grid_areas(tree, root_id, &mut styles); - if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &styles) { + if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &mut words, &styles) { let _ = taffy_tree.compute_layout( taffy_root, taffy::Size { @@ -219,7 +227,7 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { height: taffy::AvailableSpace::Definite(viewport.1), }, ); - compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &mut rects); + compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &words, &mut rects, &mut text_runs); } } @@ -228,7 +236,7 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { resolve_clip_rects(tree, root_id, None, &rects, &styles, &mut clip_rects); } - DomLayout { rects, styles, clip_rects } + DomLayout { rects, styles, clip_rects, text_runs } } /// HTML table auto-layout approximation: for each ``, the last ``/ @@ -329,27 +337,27 @@ fn compute_absolute_rects( abs_x: f32, abs_y: f32, id_map: &HashMap, + words: &HashMap, rects: &mut HashMap, + text_runs: &mut HashMap>, ) { if let Ok(layout) = taffy_tree.layout(taffy_id) { let x = abs_x + layout.location.x; let y = abs_y + layout.location.y; - + let rect = Rect { x, y, width: layout.size.width, height: layout.size.height }; + if let Some(dom_id) = id_map.get(&taffy_id) { - rects.insert( - *dom_id, - Rect { - x, - y, - width: layout.size.width, - height: layout.size.height, - }, - ); + rects.insert(*dom_id, rect); } - + // A word leaf's dom_id is its owning text node, shared by every other + // word from the same node, so this appends rather than overwrites. + if let Some((text_dom_id, word)) = words.get(&taffy_id) { + text_runs.entry(*text_dom_id).or_default().push((rect, word.clone())); + } + if let Ok(children) = taffy_tree.children(taffy_id) { for child_id in children { - compute_absolute_rects(taffy_tree, child_id, x, y, id_map, rects); + compute_absolute_rects(taffy_tree, child_id, x, y, id_map, words, rects, text_runs); } } } @@ -369,64 +377,125 @@ fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap, + words: &mut HashMap, + styles: &HashMap, +) -> Vec { + let is_text = tree + .get_node(id) + .map(|n| matches!(n.data, obscura_dom::tree::NodeData::Text { .. })) + .unwrap_or(false); + if is_text { + build_text_words(tree, id, taffy_tree, styles, words) + } else { + build(tree, id, taffy_tree, id_map, words, styles).into_iter().collect() + } +} + +/// Split a text node into one taffy leaf per word (a whitespace-delimited +/// token, each keeping a single trailing space so adjacent words stay +/// visually separated), so it can wrap across several lines within its +/// container instead of being one indivisible box. A single leaf per whole +/// text node cannot wrap internally: flex-wrap only ever breaks *between* +/// items, so a long run of plain text with no inline elements breaking it up +/// (a very common shape: prose without a link for several sentences) would +/// either fit as one giant item or overflow straight past the container's +/// edge, never wrapping onto a new line the way real text does. +fn build_text_words( + tree: &DomTree, + id: NodeId, + taffy_tree: &mut TaffyTree, + styles: &HashMap, + words: &mut HashMap, +) -> Vec { + let Some(node) = tree.get_node(id) else { return Vec::new() }; + let obscura_dom::tree::NodeData::Text { contents } = &node.data else { return Vec::new() }; + + let mut display_text = String::new(); + let mut in_space = false; + for c in contents.chars() { + if c.is_whitespace() { + if !in_space { + display_text.push(' '); + in_space = true; + } + } else { + display_text.push(c); + in_space = false; + } + } + + let mut fsize = 16.0; + let mut is_bold = false; + if let Some(parent_id) = node.parent { + if let Some(p_style) = styles.get(&parent_id) { + fsize = p_style.font_size.unwrap_or(16.0); + is_bold = p_style.font_weight.as_deref() == Some("bold"); + } + } + + tokenize_with_spaces(&display_text) + .into_iter() + .filter_map(|token| { + let width = text_width(&token, fsize, is_bold); + // A pure-whitespace token is HTML source formatting or a bare + // inter-element space; it keeps its (small) width so adjacent + // inline content stays visually separated, but contributes no + // height, so it never adds a spurious blank row when it lands + // between block-level siblings (e.g. formatting whitespace + // around a run of now-collapsed, display:none list items). + let height = if token.trim().is_empty() { 0.0 } else { fsize * 1.2 }; + let taffy_style = taffy::Style { + size: taffy::Size { width: taffy::Dimension::Length(width), height: taffy::Dimension::Length(height) }, + ..Default::default() + }; + let taffy_id = taffy_tree.new_leaf(taffy_style).ok()?; + words.insert(taffy_id, (id, token)); + Some(taffy_id) + }) + .collect() +} + +/// Split already whitespace-collapsed text into tokens, each carrying at most +/// one trailing space (`"Hello World "` -> `["Hello ", "World "]`), so a +/// word's own width naturally includes the gap to the next one. +fn tokenize_with_spaces(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut cur = String::new(); + for c in text.chars() { + cur.push(c); + if c == ' ' { + tokens.push(std::mem::take(&mut cur)); + } + } + if !cur.is_empty() { + tokens.push(cur); + } + tokens +} + fn build( tree: &DomTree, id: NodeId, taffy_tree: &mut TaffyTree, id_map: &mut HashMap, + words: &mut HashMap, styles: &HashMap, ) -> Option { let node = tree.get_node(id)?; - - if let obscura_dom::tree::NodeData::Text { contents } = &node.data { - let mut display_text = String::new(); - let mut in_space = false; - for c in contents.chars() { - if c.is_whitespace() { - if !in_space { - display_text.push(' '); - in_space = true; - } - } else { - display_text.push(c); - in_space = false; - } - } - - - let mut fsize = 16.0; - let mut is_bold = false; - if let Some(parent_id) = node.parent { - if let Some(p_style) = styles.get(&parent_id) { - fsize = p_style.font_size.unwrap_or(16.0); - is_bold = p_style.font_weight.as_deref() == Some("bold"); - } - } - - let width = text_width(&display_text, fsize, is_bold); - // A text node that is pure whitespace is almost always HTML source - // formatting (a newline and indentation between sibling tags), not - // meaningful content: real browsers collapse it away entirely. We - // still give it its natural (small) width, since a bare space - // between two inline elements ("Foo Bar") does need to - // keep them visually separated, but zero height, so it never adds a - // spurious blank row when it lands between block-level siblings - // (e.g. formatting whitespace around a run of now-collapsed, - // display:none list items). - let height = if contents.trim().is_empty() { 0.0 } else { fsize * 1.2 }; - - let taffy_style = taffy::Style { - size: taffy::Size { - width: taffy::Dimension::Length(width), - height: taffy::Dimension::Length(height), - }, - ..Default::default() - }; - let taffy_id = taffy_tree.new_leaf(taffy_style).ok()?; - id_map.insert(taffy_id, id); - return Some(taffy_id); - } - let _name = node.as_element()?; let style = styles.get(&id)?; if style.display == crate::Display::None { @@ -453,9 +522,9 @@ fn build( let dom_children = tree.children(id); let has_float_child = dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); let child_ids: Vec = if has_float_child { - build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, styles) + build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, styles) } else { - dom_children.into_iter().filter_map(|cid| build(tree, cid, taffy_tree, id_map, styles)).collect() + dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, styles)).collect() }; let taffy_id = if child_ids.is_empty() { @@ -484,6 +553,7 @@ fn build_children_with_float_zone( dom_children: &[NodeId], taffy_tree: &mut TaffyTree, id_map: &mut HashMap, + words: &mut HashMap, styles: &HashMap, ) -> Vec { let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false); @@ -495,12 +565,12 @@ fn build_children_with_float_zone( }; let Some(float_idx) = dom_children.iter().position(|&cid| is_float(cid)) else { - return dom_children.iter().filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)).collect(); + return dom_children.iter().flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, styles)).collect(); }; let mut result: Vec = dom_children[..float_idx] .iter() - .filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)) + .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, styles)) .collect(); let float_side = styles.get(&dom_children[float_idx]).and_then(|s| s.float); @@ -509,10 +579,13 @@ fn build_children_with_float_zone( zone_end += 1; } - let float_taffy = build(tree, dom_children[float_idx], taffy_tree, id_map, styles); + // The float itself is always an element (only elements get style + // entries, and `is_float` above required one), so a direct `build` call + // is correct here; only its flow siblings need the word-splitting `build_any`. + let float_taffy = build(tree, dom_children[float_idx], taffy_tree, id_map, words, styles); let flow_taffy: Vec = dom_children[float_idx + 1..zone_end] .iter() - .filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)) + .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, styles)) .collect(); match float_taffy { @@ -555,7 +628,7 @@ fn build_children_with_float_zone( result.extend( dom_children[zone_end..] .iter() - .filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)), + .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, styles)), ); result } diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 7148cff..4aa6bef 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -172,52 +172,29 @@ pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&st paint_dom(tree, viewport, base_url)?.encode_png().ok() } +/// Paint every word of a text node at its own laid-out position. A text node +/// lays out as one taffy leaf per word (see `dom::build_text_words`), each +/// wrapping independently, so its content is a list of (box, word) pairs +/// rather than one box for the whole node; color/font/clip come from the +/// parent element and are the same for every word. fn paint_text_node( tree: &DomTree, nid: obscura_dom::tree::NodeId, laid: &crate::DomLayout, pixmap: &mut Pixmap, ) -> Option<()> { + let runs = laid.text_runs.get(&nid)?; let node = tree.get_node(nid)?; - let mut text_buf = String::new(); - let text = match &node.data { - obscura_dom::tree::NodeData::Text { contents } => { - let mut in_space = false; - for c in contents.chars() { - if c.is_whitespace() { - if !in_space { - text_buf.push(' '); - in_space = true; - } - } else { - text_buf.push(c); - in_space = false; - } - } - if text_buf.is_empty() { - " " - } else { - &text_buf - } - }, - _ => return None, - }; - let parent = node.parent?; let style = laid.styles.get(&parent)?; let color = style.color.unwrap_or([0, 0, 0, 255]); let fsize = style.font_size.unwrap_or(16.0); - let is_bold = style.font_weight.as_deref() == Some("bold"); - - // Get geometry of the text node itself! - let rect = match laid.rects.get(&nid) { - Some(r) => r, - None => return None, - }; - let clip = laid.clip_rects.get(&nid).copied().flatten(); - draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip); + + for (rect, word) in runs { + draw_text(pixmap, word, rect.x, rect.y, color, fsize, is_bold, clip); + } Some(()) } @@ -301,9 +278,14 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si } } }); - caret.x += scaled_font.h_advance(id); + // Matches measure_text's +1px-per-character bold compensation: + // without it, a word's reserved layout width (from measure_text) + // is wider than what draw_text actually advances through, and + // the difference shows up as a visible gap after every word once + // each word is its own independently-positioned box. + caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 }; } else { - caret.x += scaled_font.h_advance(id); + caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 }; } } } diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 5b3a05b..4fb58f2 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -34,16 +34,23 @@ const HN_HTML: &str = r##" "##; -fn find_by_text<'a>( - tree: &'a obscura_dom::tree::DomTree, - layout: &'a obscura_render::DomLayout, +/// The top-left corner of a text node's overall bounding box: a text node +/// lays out as one taffy leaf per word (see `dom::build_text_words`), so its +/// geometry lives in `text_runs` as several (box, word) pairs rather than a +/// single rect. `needle` is matched against the node's whole original +/// content, not a single word. +fn find_by_text( + tree: &obscura_dom::tree::DomTree, + layout: &obscura_render::DomLayout, needle: &str, -) -> Option<(obscura_dom::tree::NodeId, &'a obscura_render::Rect)> { - for (id, rect) in &layout.rects { +) -> Option<(f32, f32)> { + for (id, runs) in &layout.text_runs { if let Some(node) = tree.get_node(*id) { if let NodeData::Text { contents } = &node.data { if contents.trim() == needle { - return Some((*id, rect)); + let x = runs.iter().map(|(r, _)| r.x).fold(f32::INFINITY, f32::min); + let y = runs.iter().map(|(r, _)| r.y).fold(f32::INFINITY, f32::min); + return Some((x, y)); } } } @@ -62,23 +69,50 @@ fn hn_shaped_table_lays_out_without_site_hardcoding() { // "Hacker News" (a bold link in the top bar) sits above the first // headline row ("Exapunks (2018)"): normal top-to-bottom flow, not a // hardcoded absolute position. - let (_, brand_rect) = find_by_text(&tree, &layout, "Hacker News").expect("brand text laid out"); - let (_, headline_rect) = find_by_text(&tree, &layout, "Exapunks (2018)").expect("headline text laid out"); + let (brand_x, brand_y) = find_by_text(&tree, &layout, "Hacker News").expect("brand text laid out"); + let (_, headline_y) = find_by_text(&tree, &layout, "Exapunks (2018)").expect("headline text laid out"); assert!( - headline_rect.y > brand_rect.y, + headline_y > brand_y, "headline should be below the header bar: brand.y={} headline.y={}", - brand_rect.y, - headline_rect.y + brand_y, + headline_y ); // Within the header row, "login" (right-aligned cell) sits to the right // of "Hacker News" (left cell) — plain flex/table layout, not a magic // per-class x-offset. - let (_, login_rect) = find_by_text(&tree, &layout, "login").expect("login text laid out"); + let (login_x, _) = find_by_text(&tree, &layout, "login").expect("login text laid out"); assert!( - login_rect.x > brand_rect.x, + login_x > brand_x, "login cell should be right of the brand: brand.x={} login.x={}", - brand_rect.x, - login_rect.x + brand_x, + login_x + ); +} + +#[test] +fn long_text_run_wraps_across_multiple_lines() { + // A single text node with no inline elements breaking it up must still + // wrap word by word within a narrow container: this is the regression + // case for treating a whole text node as one indivisible layout box, + // which cannot wrap internally and instead overflows straight past the + // container's edge. + let html = r##"
This sentence has plenty of words to wrap across several lines
"##; + let tree = parse_html(html); + let layout = layout_dom(&tree, (1000.0, 1000.0)); + + let text_id = tree + .descendants(tree.document()) + .into_iter() + .find(|id| matches!(tree.get_node(*id).map(|n| n.data.clone()), Some(NodeData::Text { .. }))) + .expect("text node exists"); + let runs = layout.text_runs.get(&text_id).expect("text node has word runs"); + assert!(runs.len() > 5, "expected the sentence to split into several word leaves, got {}", runs.len()); + + let distinct_y: std::collections::BTreeSet = runs.iter().map(|(r, _)| r.y.round() as i32).collect(); + assert!( + distinct_y.len() > 1, + "words should wrap onto more than one line within a 100px-wide container, got y positions {:?}", + distinct_y ); }