Split text nodes into per-word layout leaves so text actually wraps

A whole DOM text node was one indivisible taffy leaf. Flex-wrap only
  breaks between items, never inside one, so a long run of plain text
  with no inline element breaking it up (an ordinary sentence with no
  link in it) either fit on one line or ran straight past the
  container's edge instead of wrapping. Short runs interrupted by links
  every few words (extremely common in Wikipedia's markup) happened to
  be narrow enough to wrap on their own, which is why paragraphs looked
  fine but any long plain-text stretch overflowed straight over the
  infobox and the History-section image.

  Text nodes now split into one taffy leaf per word, flattened into the
  same child list as sibling elements so words from different text nodes
  and inline elements interleave and wrap together like real inline
  flow. This changes the geometry data model: a text node's content is
  now a list of (box, word) pairs (DomLayout::text_runs) instead of a
  single box, since it can span several lines. Updated painting to draw
  each word at its own position, and fixed a bold-text measurement
  mismatch (layout width and drawn width disagreed by 1px/char, invisible
  before as one blob, visible as gaps between every word once each word
  is independently positioned).

  Added a regression test that fails against the old single-leaf
  behavior.
This commit is contained in:
SGavrl committed 2026-07-03 12:59:40 +00:00
1 parent 55e6787b0b
commit 88313178b2
3 files changed
+211 -122

No files matched your search

+145 -72
View File
@@ -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<NodeId, Option<Rect>>,
/// 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<NodeId, Vec<(Rect, String)>>,
}
/// 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<taffy::NodeId, NodeId> = HashMap::new();
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
// The document node itself is not an element; lay out from the first
// element descendant (the <html> 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 `<tr>`, the last `<td>`/
@@ -329,27 +337,27 @@ fn compute_absolute_rects(
abs_x: f32,
abs_y: f32,
id_map: &HashMap<taffy::NodeId, NodeId>,
words: &HashMap<taffy::NodeId, (NodeId, String)>,
rects: &mut HashMap<NodeId, Rect>,
text_runs: &mut HashMap<NodeId, Vec<(Rect, String)>>,
) {
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<NodeId, crate
})
}
/// Build whichever of an element or a text node `id` is, returning every
/// taffy node it produced (usually one, but a text node fans out to one leaf
/// per word; see `build_text_words`). Callers collecting a container's
/// children should use this instead of calling `build` directly, so text
/// nodes flatten into the same child list as their sibling elements rather
/// than being skipped or nested a level deeper (either of which would break
/// wrapping: the whole point is for words from different text nodes and
/// inline elements to sit as flat, interleaved siblings that a flex-wrap row
/// can break between anywhere).
fn build_any(
tree: &DomTree,
id: NodeId,
taffy_tree: &mut TaffyTree,
id_map: &mut HashMap<taffy::NodeId, NodeId>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Vec<taffy::NodeId> {
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<NodeId, crate::LayoutStyle>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
) -> Vec<taffy::NodeId> {
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<String> {
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<taffy::NodeId, NodeId>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<taffy::NodeId> {
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 ("<a>Foo</a> <a>Bar</a>") 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<taffy::NodeId> = 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<taffy::NodeId, NodeId>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Vec<taffy::NodeId> {
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<taffy::NodeId> = 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<taffy::NodeId> = 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
}
+17 -35
View File
@@ -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 };
}
}
}
+49 -15
View File
@@ -34,16 +34,23 @@ const HN_HTML: &str = r##"
</table>
"##;
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##"<div style="width:100px">This sentence has plenty of words to wrap across several lines</div>"##;
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<i32> = 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
);
}