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

+208 -2
View File
@@ -105,6 +105,15 @@ pub enum PseudoClass {
Enabled,
Disabled,
Checked,
/// `:link` / `:any-link`: an `<a>`/`<area>` with an `href`. Extremely
/// common (`a:link { color: ... }` is the standard way sites set their
/// base link color), so treating it as unsupported silently drops the
/// whole rule from the cascade, not just the pseudo-class.
Link,
/// `:visited`. We have no browsing history, so this never matches, the
/// same fallback real browsers use when history-based styling is
/// suppressed for privacy. `:link`/unvisited styling wins by default.
Visited,
}
impl parser::NonTSPseudoClass for PseudoClass {
@@ -138,6 +147,8 @@ impl ToCss for PseudoClass {
PseudoClass::Enabled => dest.write_str(":enabled"),
PseudoClass::Disabled => dest.write_str(":disabled"),
PseudoClass::Checked => dest.write_str(":checked"),
PseudoClass::Link => dest.write_str(":link"),
PseudoClass::Visited => dest.write_str(":visited"),
}
}
}
@@ -188,6 +199,8 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
"enabled" => Ok(PseudoClass::Enabled),
"disabled" => Ok(PseudoClass::Disabled),
"checked" => Ok(PseudoClass::Checked),
"link" | "any-link" => Ok(PseudoClass::Link),
"visited" => Ok(PseudoClass::Visited),
_ => Err(cssparser::ParseError {
kind: cssparser::ParseErrorKind::Custom(
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
@@ -394,10 +407,21 @@ impl<'a> Element for DomElement<'a> {
fn match_non_ts_pseudo_class(
&self,
_pc: &PseudoClass,
pc: &PseudoClass,
_context: &mut MatchingContext<'_, Self::Impl>,
) -> bool {
false
match pc {
PseudoClass::Link => self.is_link(),
PseudoClass::Visited => false,
// Dynamic user-interaction and form-state pseudo-classes have no
// meaning against a static DOM snapshot with no live user input.
PseudoClass::Hover
| PseudoClass::Active
| PseudoClass::Focus
| PseudoClass::Enabled
| PseudoClass::Disabled
| PseudoClass::Checked => false,
}
}
fn match_pseudo_element(
@@ -662,6 +686,188 @@ impl DomTree {
}
Ok(results)
}
/// Parse a single selector once for repeated single-element matching, and
/// precompute its specificity, ancestor hashes, and "subject key" (the
/// rightmost id/class/tag used to bucket the rule for fast candidate
/// lookup). Returns `None` if the selector fails to parse.
///
/// This is the primitive a stylesheet cascade builds on: compile every rule
/// once, index by key, then only test the handful of candidate rules against
/// each element instead of scanning the whole tree per rule.
pub fn compile_rule_selector(&self, selector: &str) -> Option<CompiledSelector> {
let list = parse_selector(selector).ok()?;
let sel = list.slice().first()?.clone();
let specificity = sel.specificity();
let key = subject_key(&sel);
let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks);
Some(CompiledSelector { sel, specificity, key, hashes })
}
/// Does a single element match a precompiled selector? Allocates a fresh
/// match cache each call; for many matches in a row use [`DomTree::matcher`].
pub fn element_matches(&self, nid: NodeId, compiled: &CompiledSelector) -> bool {
self.matcher().matches(self, nid, compiled)
}
/// A reusable matcher that holds the selector caches and an ancestor bloom
/// filter, so a cascade can fast-reject most (element, rule) pairs without
/// walking the selector's combinators. Reuse one across a whole cascade
/// pass and drive [`Matcher::push_ancestor`] / [`Matcher::pop_ancestor`] as
/// you descend/ascend the tree so the filter tracks the current path.
pub fn matcher(&self) -> Matcher {
Matcher {
caches: selectors::context::SelectorCaches::default(),
ancestors: AncestorFilter::new(),
}
}
}
/// Holds the servo selector match caches plus an incremental ancestor bloom
/// filter so a treewalk cascade can test thousands of (element, rule) pairs
/// cheaply: most rules are fast-rejected by the filter before any combinator
/// walking happens.
pub struct Matcher {
caches: selectors::context::SelectorCaches,
ancestors: AncestorFilter,
}
impl Matcher {
/// Match `nid` (matched as if it were the subject element; the ancestor
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
if !tree.with_node(nid, |n| n.is_element()).unwrap_or(false) {
return false;
}
let mut context = MatchingContext::new(
MatchingMode::Normal,
Some(self.ancestors.filter()),
&mut self.caches,
QuirksMode::NoQuirks,
NeedsSelectorFlags::No,
MatchingForInvalidation::No,
);
let element = DomElement::new(tree, nid);
selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context)
}
/// Push `nid`'s hashes onto the ancestor filter before descending into its
/// children. Must be paired with a matching [`Matcher::pop_ancestor`].
pub fn push_ancestor(&mut self, tree: &DomTree, nid: NodeId) {
self.ancestors.push(tree, nid);
}
/// Pop the hashes pushed by the most recent unmatched [`Matcher::push_ancestor`].
pub fn pop_ancestor(&mut self) {
self.ancestors.pop();
}
}
/// An incremental ancestor bloom filter (the same structure browsers use to
/// fast-reject selector matches during a treewalk). `push` is called with each
/// element on the way down the tree, `pop` on the way back up, so at any point
/// the filter holds exactly the hashes of the current node's ancestor chain.
struct AncestorFilter {
filter: Box<selectors::bloom::BloomFilter>,
/// Hashes pushed per tree-depth level, so `pop` knows what to remove.
levels: Vec<Vec<u32>>,
}
impl AncestorFilter {
fn new() -> Self {
AncestorFilter { filter: Box::default(), levels: Vec::new() }
}
fn filter(&self) -> &selectors::bloom::BloomFilter {
&self.filter
}
fn push(&mut self, tree: &DomTree, nid: NodeId) {
let mut hashes = Vec::new();
tree.with_node(nid, |node| {
if let Some(elem) = node.as_element() {
push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash());
if let Some(id) = node.get_attribute("id") {
push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash());
}
if let Some(class) = node.get_attribute("class") {
for c in class.split_whitespace() {
push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash());
}
}
}
});
self.levels.push(hashes);
}
fn pop(&mut self) {
if let Some(hashes) = self.levels.pop() {
for h in hashes {
self.filter.remove_hash(h);
}
}
}
}
fn push_hash(hashes: &mut Vec<u32>, filter: &mut selectors::bloom::BloomFilter, hash: u32) {
let masked = hash & selectors::bloom::BLOOM_HASH_MASK;
filter.insert_hash(masked);
hashes.push(masked);
}
/// The rightmost simple selector used to index a rule for fast lookup. A rule is
/// only tested against elements that carry its key.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SelectorKey {
Id(String),
Class(String),
Local(String),
Universal,
}
/// A parsed selector plus its cached specificity and subject key.
pub struct CompiledSelector {
sel: parser::Selector<ObscuraSelector>,
specificity: u32,
key: SelectorKey,
hashes: parser::AncestorHashes,
}
impl CompiledSelector {
pub fn key(&self) -> &SelectorKey {
&self.key
}
pub fn specificity(&self) -> u32 {
self.specificity
}
}
/// Pick the index key from a selector's rightmost compound: prefer id, then
/// class, then local name; fall back to Universal for `*`, attribute-only, or
/// pseudo-only subjects.
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> SelectorKey {
use parser::Component;
let mut local: Option<String> = None;
let mut class: Option<String> = None;
let mut id: Option<String> = None;
for comp in sel.iter_raw_match_order() {
match comp {
Component::Combinator(_) => break,
Component::ID(v) => id = Some(v.0.clone()),
Component::Class(v) => class = Some(v.0.clone()),
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
_ => {}
}
}
if let Some(i) = id {
SelectorKey::Id(i)
} else if let Some(c) = class {
SelectorKey::Class(c)
} else if let Some(l) = local {
SelectorKey::Local(l)
} else {
SelectorKey::Universal
}
}
#[cfg(test)]