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:
1 parent
cd1ee2dcc5
commit
920977776c
12 files changed
+1949
-225
No files matched your search
@@ -0,0 +1,258 @@
|
||||
//! Stylesheet cascade: parse `<style>` text into rules once, index each rule by
|
||||
//! its subject key (id / class / tag), and resolve the matching declarations for
|
||||
//! an element by testing only the handful of candidate rules that share a key.
|
||||
//!
|
||||
//! This replaces the naive "run every selector against the whole tree" approach,
|
||||
//! which is O(rules x nodes) and dominated render time on large pages (thousands
|
||||
//! of rules). The indexed cascade is closer to how real browsers match: bucket
|
||||
//! rules, gather candidates per element, then match and sort by specificity.
|
||||
|
||||
use obscura_dom::selector::{CompiledSelector, Matcher, SelectorKey};
|
||||
use obscura_dom::tree::{DomTree, NodeId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::LayoutStyle;
|
||||
|
||||
struct Rule {
|
||||
sel: CompiledSelector,
|
||||
decls: String,
|
||||
/// Source order, for breaking specificity ties (later wins).
|
||||
order: usize,
|
||||
}
|
||||
|
||||
/// An indexed set of author rules ready for fast per-element matching.
|
||||
pub struct Stylesheet {
|
||||
rules: Vec<Rule>,
|
||||
by_id: HashMap<String, Vec<usize>>,
|
||||
by_class: HashMap<String, Vec<usize>>,
|
||||
by_local: HashMap<String, Vec<usize>>,
|
||||
universal: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Stylesheet {
|
||||
/// Parse and index a set of raw CSS sources (the text of each `<style>`
|
||||
/// block, in document order). Selectors that fail to parse are dropped.
|
||||
pub fn parse(tree: &DomTree, sources: &[String]) -> Self {
|
||||
let mut sheet = Stylesheet {
|
||||
rules: Vec::new(),
|
||||
by_id: HashMap::new(),
|
||||
by_class: HashMap::new(),
|
||||
by_local: HashMap::new(),
|
||||
universal: Vec::new(),
|
||||
};
|
||||
let mut order = 0usize;
|
||||
for src in sources {
|
||||
for (selector, decls) in parse_stylesheet(src) {
|
||||
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
|
||||
let idx = sheet.rules.len();
|
||||
match sel.key() {
|
||||
SelectorKey::Id(v) => sheet.by_id.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Class(v) => sheet.by_class.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Local(v) => sheet.by_local.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Universal => sheet.universal.push(idx),
|
||||
}
|
||||
sheet.rules.push(Rule { sel, decls, order });
|
||||
order += 1;
|
||||
}
|
||||
}
|
||||
sheet
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn debug_stats(&self) -> (usize, usize, usize, usize, usize) {
|
||||
(self.rules.len(), self.by_id.len(), self.by_class.len(), self.by_local.len(), self.universal.len())
|
||||
}
|
||||
|
||||
/// Apply every author rule that matches `nid` to `style`, in cascade order
|
||||
/// (ascending specificity, then source order, so the winner is applied last).
|
||||
/// `id`, `classes`, and `local` are the element's precomputed keys.
|
||||
pub fn apply(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
id: Option<&str>,
|
||||
classes: &[String],
|
||||
local: &str,
|
||||
style: &mut LayoutStyle,
|
||||
) {
|
||||
// (specificity, order, rule index) for each matching rule.
|
||||
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
|
||||
if let Some(idxs) = bucket {
|
||||
for &i in idxs {
|
||||
let rule = &self.rules[i];
|
||||
if matcher.matches(tree, nid, &rule.sel) {
|
||||
matched.push((rule.sel.specificity(), rule.order, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
consider(self.by_local.get(local), &mut matched);
|
||||
if let Some(id) = id {
|
||||
consider(self.by_id.get(id), &mut matched);
|
||||
}
|
||||
for c in classes {
|
||||
consider(self.by_class.get(c), &mut matched);
|
||||
}
|
||||
if !self.universal.is_empty() {
|
||||
consider(Some(&self.universal), &mut matched);
|
||||
}
|
||||
|
||||
if matched.is_empty() {
|
||||
return;
|
||||
}
|
||||
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, i) in matched {
|
||||
crate::style::apply_inline(style, &self.rules[i].decls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
|
||||
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
|
||||
/// (`@media`, `@supports`); other at-rules (`@font-face`, `@keyframes`, ...) are
|
||||
/// skipped since they do not contribute layout-relevant declarations here.
|
||||
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||
let mut rules = Vec::new();
|
||||
let mut current_selector = String::new();
|
||||
let mut current_decls = String::new();
|
||||
let mut block_depth = 0;
|
||||
let mut in_comment = false;
|
||||
let mut chars = css.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if in_comment {
|
||||
if c == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
in_comment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_comment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '{' {
|
||||
if block_depth != 0 {
|
||||
current_decls.push(c);
|
||||
}
|
||||
block_depth += 1;
|
||||
} else if c == '}' {
|
||||
block_depth -= 1;
|
||||
if block_depth == 0 {
|
||||
let sel = current_selector.trim();
|
||||
let decls = current_decls.trim();
|
||||
if let Some(at) = sel.strip_prefix('@') {
|
||||
flush_at_rule(at, sel, decls, &mut rules);
|
||||
} else {
|
||||
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 {
|
||||
current_decls.push(c);
|
||||
}
|
||||
} else if block_depth > 0 {
|
||||
current_decls.push(c);
|
||||
} else {
|
||||
current_selector.push(c);
|
||||
}
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
/// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply
|
||||
/// the inner rules only when the query holds for a desktop 1280px viewport.
|
||||
fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, String)>) {
|
||||
if at.starts_with("media") {
|
||||
if media_query_applies(sel) {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
}
|
||||
} else if at.starts_with("supports") {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
}
|
||||
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
|
||||
// layout-relevant rules for us, so drop them.
|
||||
}
|
||||
|
||||
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport.
|
||||
///
|
||||
/// Real stylesheets format media features inconsistently
|
||||
/// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this
|
||||
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
|
||||
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
||||
/// than special-case every formatting variant a site might use.
|
||||
fn media_query_applies(query: &str) -> bool {
|
||||
const VIEWPORT_W: f32 = 1280.0;
|
||||
if query.contains("print") {
|
||||
return false;
|
||||
}
|
||||
let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if let Some(px) = extract_px(&compact, "max-width:") {
|
||||
if VIEWPORT_W > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "min-width:") {
|
||||
if VIEWPORT_W < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Read the number immediately following `prop` in `s`. Callers pass a
|
||||
/// whitespace-stripped `s`, so the first non-digit character always ends the
|
||||
/// number. Handles a bare px value (`750px`) and the extremely common
|
||||
/// responsive-breakpoint idiom `calc(750px - 1px)` (real stylesheets use this
|
||||
/// constantly to express "one pixel narrower than the next breakpoint"):
|
||||
/// evaluated as a simple left-to-right sum of every px term, since css media
|
||||
/// feature calc() expressions in practice are always plain +/- of px values,
|
||||
/// never nested, multiplied, or mixed-unit.
|
||||
fn extract_px(s: &str, prop: &str) -> Option<f32> {
|
||||
let start = s.find(prop)? + prop.len();
|
||||
let rest = &s[start..];
|
||||
if let Some(inner) = rest.strip_prefix("calc(") {
|
||||
let end = inner.find(')')?;
|
||||
return Some(eval_px_sum(&inner[..end]));
|
||||
}
|
||||
let num: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
|
||||
num.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// Sum a sequence of signed px terms like `1120px-1px` (whitespace already
|
||||
/// stripped by the caller) left to right.
|
||||
fn eval_px_sum(expr: &str) -> f32 {
|
||||
let mut total = 0.0;
|
||||
let mut sign = 1.0;
|
||||
let mut num = String::new();
|
||||
let flush = |num: &mut String, sign: f32, total: &mut f32| {
|
||||
if let Ok(v) = num.parse::<f32>() {
|
||||
*total += sign * v;
|
||||
}
|
||||
num.clear();
|
||||
};
|
||||
for c in expr.chars() {
|
||||
match c {
|
||||
'+' => { flush(&mut num, sign, &mut total); sign = 1.0; }
|
||||
'-' => { flush(&mut num, sign, &mut total); sign = -1.0; }
|
||||
c if c.is_ascii_digit() || c == '.' => num.push(c),
|
||||
_ => {} // unit suffix (px) or anything else: not part of the number
|
||||
}
|
||||
}
|
||||
flush(&mut num, sign, &mut total);
|
||||
total
|
||||
}
|
||||
Reference in new issue
Block a user