Fix render engine: CSS grid, cascade correctness, SVG, inline flow

The render layer had no CSS Grid support, so Vector 2022's page layout
  (three nested grids) collapsed to a vertical stack with the article
  landing tens of thousands of pixels down the page. Wikipedia rendered
  as a near-blank page and Hacker News only worked through per-site
  hardcoded CSS overrides.

  Removed the HN-specific hardcoding and fixed the general engine instead:

  - CSS Grid layout (grid-template-areas/columns/rows via taffy's grid mode)
  - Indexed, specificity-correct cascade in a new css.rs, replacing the
    old test-every-selector-against-every-node scan. Cascade time on
    Wikipedia went from 9.5s to under 200ms.
  - Ancestor bloom-filter fast-rejection for selector matching in
    obscura-dom, needed to make the indexed cascade actually fast on
    descendant combinators.
  - :link/:visited pseudo-classes were previously unparseable, so any
    rule using them (a:link{color:...}, extremely common) silently
    dropped the whole rule from the cascade.
  - overflow:hidden clipping, fixing the pervasive visually-hidden-but-
    accessible pattern used for skip links and screen-reader-only labels.
  - @media parser robustness: it did a literal substring search for
    max-width: and silently dropped the constraint on max-width : 750px
    (space before colon) or max-width:calc(750px - 1px), treating
    mobile-only or narrow-viewport rules as always active. The calc()
    case was hiding Wikipedia's TOC sidebar via a rule meant only for
    narrow screens.
  - Table auto-layout approximation: the trailing auto-width td in a row
    absorbs leftover space, so a width:100% table's unsized last column
    reaches the right edge instead of shrinking to content.
  - Inline formatting context approximation: a block holding inline-level
    children (a paragraph or list item with links) is promoted to a
    wrapping flex row so text and links read as continuous prose instead
    of one fragment per line.
  - Whitespace-only text nodes no longer get a full line-height box. They
    used to stack up as blank rows, turning Wikipedia's collapsed TOC
    subsections and the page header into hundreds of pixels of dead space.
  - SVG rendering via resvg/usvg, plus resolving relative image URLs
    against the page's base URL.
  - background-image support including background-size/background-position
    for correctly sized and positioned icons.
  - Per-screenshot image fetch cache.
  - Fixed the layout-only (no paint feature) build, which didn't compile
    due to an unconditional call into paint-gated code.
This commit is contained in:
SGavrl committed 2026-07-03 12:04:03 +00:00
1 parent cd1ee2dcc5
commit 920977776c
12 files changed
+1949 -225

No files matched your search

+265 -108
View File
@@ -13,53 +13,134 @@ use taffy::prelude::*;
use crate::{Rect, to_taffy_style};
/// Text width for layout. With the `paint` feature this is exact (real glyph
/// metrics from the embedded font, shared with rasterization). Without it
/// (layout-only builds, e.g. for `getBoundingClientRect`), fall back to a
/// standard average-character-width heuristic for proportional fonts so
/// `obscura-render` compiles and gives reasonable geometry with its default
/// (lightest-weight) feature set, matching RENDER.md's documented "layout
/// (default)" mode.
#[cfg(feature = "paint")]
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
crate::paint::measure_text(text, size, is_bold)
}
#[cfg(not(feature = "paint"))]
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
const AVG_CHAR_WIDTH_EM: f32 = 0.55;
let chars = text.chars().filter(|c| !c.is_control()).count() as f32;
let width = chars * size * AVG_CHAR_WIDTH_EM;
if is_bold { width * 1.08 } else { width }
}
/// Per-element border boxes after layout, in viewport coordinates.
pub struct DomLayout {
pub rects: HashMap<NodeId, Rect>,
pub styles: HashMap<NodeId, crate::LayoutStyle>,
/// The clip rect inherited from ancestor `overflow: hidden` boxes, keyed
/// 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>>,
}
/// Walk the tree top-down accumulating the clip rect imposed by ancestor
/// `overflow: hidden` boxes. Must run after layout, since it needs border
/// boxes. This is what makes `overflow: hidden` (used pervasively for the
/// "visually hidden but accessible" pattern: a 1x1 absolutely-positioned box
/// with clipped overflow) actually hide its contents instead of letting text
/// paint wherever the box's static position happens to land.
fn resolve_clip_rects(
tree: &DomTree,
id: NodeId,
inherited: Option<Rect>,
rects: &HashMap<NodeId, Rect>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
clip_rects: &mut HashMap<NodeId, Option<Rect>>,
) {
clip_rects.insert(id, inherited);
let next = match (styles.get(&id), rects.get(&id)) {
(Some(style), Some(rect)) if style.overflow_hidden => {
Some(match inherited {
Some(clip) => clip.intersect(rect).unwrap_or(Rect::default()),
None => *rect,
})
}
_ => inherited,
};
for cid in tree.children(id) {
resolve_clip_rects(tree, cid, next, rects, styles, clip_rects);
}
}
/// Compute the UA + author style for every element in preorder, maintaining
/// `matcher`'s ancestor filter as we descend so descendant-combinator rules
/// fast-reject correctly. Non-element nodes (text, comments) are skipped but
/// still walked through, since an element may be their descendant.
fn cascade_walk(
tree: &DomTree,
id: NodeId,
sheet: &crate::css::Stylesheet,
matcher: &mut obscura_dom::selector::Matcher,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
) {
let Some(node) = tree.get_node(id) else { return };
let is_element = node.as_element().map(|elem| {
let mut style = crate::style::ua_style(elem.local.as_ref());
if !sheet.is_empty() {
let node_id = node.get_attribute("id");
let classes: Vec<String> = node
.get_attribute("class")
.map(|c| c.split_whitespace().map(|s| s.to_string()).collect())
.unwrap_or_default();
sheet.apply(tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style);
}
styles.insert(id, style);
});
if is_element.is_some() {
matcher.push_ancestor(tree, id);
}
for cid in tree.children(id) {
cascade_walk(tree, cid, sheet, matcher, styles);
}
if is_element.is_some() {
matcher.pop_ancestor();
}
}
/// Lay out a DOM tree within `viewport` (width, height) in CSS pixels.
pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
let mut css_rules = Vec::new();
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
// Collect the text of every <style> block in document order.
let mut css_sources = Vec::new();
for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) {
if let Some(elem) = node.as_element() {
if elem.local.as_ref() == "style" {
let css = tree.text_content(nid);
css_rules.extend(parse_simple_css(&css));
css_sources.push(tree.text_content(nid));
}
}
}
}
let t0 = std::time::Instant::now();
let sheet = crate::css::Stylesheet::parse(tree, &css_sources);
let t_parse = t0.elapsed();
let t1 = std::time::Instant::now();
let mut matcher = tree.matcher();
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) {
if let Some(elem) = node.as_element() {
styles.insert(nid, crate::style::ua_style(elem.local.as_ref()));
}
}
// A real preorder walk (not a flat descendants() scan) so the matcher's
// ancestor bloom filter tracks the current path: push before recursing
// into children, pop on the way back out. This is what lets descendant
// combinators (".mw-body .firstHeading") fast-reject via the filter
// instead of falling back to the always-true "can't reject" case.
cascade_walk(tree, tree.document(), &sheet, &mut matcher, &mut styles);
if timing {
let (r, i, c, l, u) = sheet.debug_stats();
eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={}", t_parse, t1.elapsed(), r, i, c, l, u);
}
css_rules.push((".subtext".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push((".subline".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push((".hnname".into(), "margin-right: 10px; margin-left: 2px".into()));
css_rules.push((".pagetop".into(), "align-items: center".into()));
css_rules.push((".votearrow".into(), "width: 10px; height: 10px; margin-top: 2px".into()));
css_rules.push((".rank".into(), "color: #828282".into()));
css_rules.push((".comhead".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push(("a".into(), "color: #000000".into()));
for (selector, decls) in &css_rules {
if let Ok(matched) = tree.query_selector_all(selector) {
for nid in matched {
if let Some(style) = styles.get_mut(&nid) {
crate::style::apply_inline(style, decls);
}
}
}
}
for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) {
if node.is_element() {
@@ -90,21 +171,13 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
crate::style::apply_inline(style, &format!("height: {}", height));
}
}
// For table cells with right alignment, make them grow to push content to the right
if let Some(elem) = node.as_element() {
let local = elem.local.as_ref();
if local == "td" || local == "th" {
let is_title = node.get_attribute("class").unwrap_or_default().contains("title");
if !is_title && style.justify_content == Some(taffy::JustifyContent::FlexEnd) {
style.flex_grow = Some(1.0);
}
}
}
}
}
}
}
grow_trailing_auto_cells(tree, &mut styles);
let mut taffy_tree: TaffyTree = TaffyTree::new();
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
@@ -117,25 +190,27 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
let mut rects = HashMap::new();
if let Some(root_id) = root {
let mut queue = vec![(root_id, None, None)];
while let Some((id, mut parent_color, mut parent_size)) = queue.pop() {
// Top-down inheritance of the properties CSS inherits by default.
#[derive(Clone, Default)]
struct Inherited {
color: Option<[u8; 4]>,
font_size: Option<f32>,
font_weight: Option<String>,
}
let mut queue = vec![(root_id, Inherited::default())];
while let Some((id, mut inh)) = queue.pop() {
if let Some(style) = styles.get_mut(&id) {
if style.color.is_some() {
parent_color = style.color;
} else if parent_color.is_some() {
style.color = parent_color;
}
if style.font_size.is_some() {
parent_size = style.font_size;
} else if parent_size.is_some() {
style.font_size = parent_size;
}
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size }
match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() }
}
for cid in tree.children(id).into_iter().rev() {
queue.push((cid, parent_color, parent_size));
queue.push((cid, inh.clone()));
}
}
resolve_grid_areas(tree, root_id, &mut styles);
if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &styles) {
let _ = taffy_tree.compute_layout(
taffy_root,
@@ -147,7 +222,105 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &mut rects);
}
}
DomLayout { rects, styles }
let mut clip_rects = HashMap::new();
if let Some(root_id) = root {
resolve_clip_rects(tree, root_id, None, &rects, &styles, &mut clip_rects);
}
DomLayout { rects, styles, clip_rects }
}
/// HTML table auto-layout approximation: for each `<tr>`, the last `<td>`/
/// `<th>` child with no explicit width absorbs the row's leftover space. Real
/// browsers run a genuine column-width-negotiation algorithm across every row
/// in a table; giving `flex_grow` to *every* auto-width cell approximates that
/// badly; multiple growing siblings compete and the split becomes sensitive to
/// each cell's own content size, which looks like near-random drift row to
/// row. Growing only the trailing auto cell matches the extremely common
/// layout intent (fixed-size leading cells, one expanding trailing cell) and
/// leaves the others shrink-to-fit, matching a shrink-to-fit table exactly
/// when there is no surplus width to distribute in the first place.
fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
let is_tag = |id: NodeId, tags: &[&str]| -> bool {
match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) {
Some(local) => tags.contains(&local.as_str()),
None => false,
}
};
for tr in tree.descendants(tree.document()) {
if !is_tag(tr, &["tr"]) {
continue;
}
let last_auto_cell = tree.children(tr).into_iter().rev().find(|&cid| {
is_tag(cid, &["td", "th"])
&& styles.get(&cid).map(|s| s.width == crate::Dimension::Auto && s.flex_grow.is_none()).unwrap_or(false)
});
if let Some(cid) = last_auto_cell {
if let Some(style) = styles.get_mut(&cid) {
style.flex_grow = Some(1.0);
}
}
}
}
/// Walk the tree; for each `display: grid` element that declares
/// `grid-template-areas`, resolve each direct child's `grid-area` name to a
/// taffy line placement. This is how Vector 2022 (and most grid layouts) place
/// their header/sidebar/content/footer regions.
fn resolve_grid_areas(
tree: &DomTree,
root: NodeId,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
) {
let mut stack = vec![root];
while let Some(id) = stack.pop() {
for cid in tree.children(id) {
stack.push(cid);
}
let areas = match styles.get(&id) {
Some(s) if s.display == crate::Display::Grid => match &s.grid_areas {
Some(a) if !a.is_empty() => a.clone(),
_ => continue,
},
_ => continue,
};
// name -> (row_start, row_end, col_start, col_end) in 0-based track indices.
let mut spans: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
for (r, row) in areas.iter().enumerate() {
for (c, name) in row.iter().enumerate() {
if name == "." {
continue;
}
spans
.entry(name.clone())
.and_modify(|s| {
s.0 = s.0.min(r);
s.1 = s.1.max(r);
s.2 = s.2.min(c);
s.3 = s.3.max(c);
})
.or_insert((r, r, c, c));
}
}
for cid in tree.children(id) {
let Some(cstyle) = styles.get_mut(&cid) else { continue };
let Some(name) = cstyle.grid_area_name.clone() else { continue };
if let Some(&(r0, r1, c0, c1)) = spans.get(&name) {
use taffy::style_helpers::line;
cstyle.grid_row = Some(taffy::Line {
start: line((r0 + 1) as i16),
end: line((r1 + 2) as i16),
});
cstyle.grid_column = Some(taffy::Line {
start: line((c0 + 1) as i16),
end: line((c1 + 2) as i16),
});
}
}
}
}
fn compute_absolute_rects(
@@ -182,36 +355,18 @@ fn compute_absolute_rects(
}
}
fn parse_simple_css(css: &str) -> Vec<(String, String)> {
let mut rules = Vec::new();
let mut current_selector = String::new();
let mut current_decls = String::new();
let mut in_block = false;
for c in css.chars() {
if c == '{' && !in_block {
in_block = true;
} else if c == '}' && in_block {
in_block = false;
let sel = current_selector.trim();
let decls = current_decls.trim();
for s in sel.split(',') {
let s = s.trim();
if !s.is_empty() {
rules.push((s.to_string(), decls.to_string()));
}
}
current_selector.clear();
current_decls.clear();
} else {
if in_block {
current_decls.push(c);
} else {
current_selector.push(c);
}
/// Does `id` have any direct child that is inline-level (a non-whitespace
/// text node, or an element whose resolved display is `Inline`)? Used to
/// decide whether a block container needs the flex-row-wrap approximation of
/// an inline formatting context.
fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
tree.children(id).into_iter().any(|cid| {
let Some(node) = tree.get_node(cid) else { return false };
match &node.data {
obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(),
_ => styles.get(&cid).map(|s| s.display == crate::Display::Inline).unwrap_or(false),
}
}
rules
})
}
fn build(
@@ -237,10 +392,8 @@ fn build(
in_space = false;
}
}
if display_text == " " {
display_text = " ".to_string(); // Keep single space
}
let mut fsize = 16.0;
let mut is_bold = false;
if let Some(parent_id) = node.parent {
@@ -250,9 +403,18 @@ fn build(
}
}
let width = crate::paint::measure_text(&display_text, fsize, is_bold);
let height = fsize * 1.2;
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),
@@ -271,26 +433,21 @@ fn build(
return None;
}
let mut taffy_style = to_taffy_style(style);
// Emulate HTML table layout by giving the last td in a tr all remaining space
if _name.local.as_ref() == "td" || _name.local.as_ref() == "th" {
if let Some(parent_id) = node.parent {
if let Some(parent) = tree.get_node(parent_id) {
if parent.as_element().map_or(false, |e| e.local.as_ref() == "tr") {
let mut last_td = None;
for cid in tree.children(parent_id) {
if let Some(child) = tree.get_node(cid) {
if child.as_element().map_or(false, |e| e.local.as_ref() == "td" || e.local.as_ref() == "th") {
last_td = Some(cid);
}
}
}
if last_td == Some(id) {
taffy_style.flex_grow = 1.0;
}
}
}
}
// Taffy has no real inline formatting context: its native Block layout
// treats every direct child as its own full-width block box, stacked
// vertically. That is correct when a block's children are other block
// elements (a div full of divs), but wrong the moment a block holds
// inline-level content (running text with <a>/<span>/<b> mixed in, i.e.
// any ordinary paragraph or list item), where real CSS instead lays
// consecutive inline boxes out left-to-right, wrapping at the container's
// width. Approximate that inline formatting context by promoting such
// blocks to a wrapping flex row, so text and inline elements actually
// flow together on shared lines instead of each getting its own row.
if style.display == crate::Display::Block && has_inline_content(tree, id, styles) {
taffy_style.display = taffy::style::Display::Flex;
taffy_style.flex_direction = taffy::FlexDirection::Row;
taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
}
let child_ids: Vec<taffy::NodeId> = tree