Files
obscura/crates/obscura-render/tests/layout_test.rs
T
SGavrl 88313178b2 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.
2026-07-03 12:59:40 +00:00

119 lines
5.4 KiB
Rust

//! Regression test: a Hacker-News-shaped nested `<table>` layout must come out
//! right using only the general engine (UA defaults + real CSS cascade), with
//! no per-site hardcoded selectors. This guards against reintroducing the
//! site-specific hacks that used to live in obscura-render for this exact markup.
use obscura_dom::tree::NodeData;
use obscura_dom::tree_sink::parse_html;
use obscura_render::layout_dom;
const HN_HTML: &str = r##"
<table border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
<tr>
<td bgcolor="#ff6600">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
<tr>
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18"></a></td>
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b> <a href="newest">new</a> | <a href="front">past</a></span></td>
<td style="text-align:right;padding-right:4px;"><span class="pagetop"><a href="login?goto=news">login</a></span></td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tr class="athing submission" id="48761229">
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
<td valign="top" class="votelinks"><center><a id="up_48761229" href="vote?id=48761229"><div class="votearrow" title="upvote"></div></a></center></td>
<td class="title"><span class="titleline"><a href="https://www.zachtronics.com/exapunks/">Exapunks (2018)</a></span></td>
</tr>
</table>
</td>
</tr>
</table>
"##;
/// 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<(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 {
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));
}
}
}
}
None
}
#[test]
fn hn_shaped_table_lays_out_without_site_hardcoding() {
let tree = parse_html(HN_HTML);
let layout = layout_dom(&tree, (1000.0, 1000.0));
// Every element got a rect; the tree isn't just being dropped.
assert!(layout.rects.len() > 10, "expected many laid-out elements, got {}", layout.rects.len());
// "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_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_y > brand_y,
"headline should be below the header bar: brand.y={} headline.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_x, _) = find_by_text(&tree, &layout, "login").expect("login text laid out");
assert!(
login_x > brand_x,
"login cell should be right of the brand: brand.x={} login.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
);
}