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

+78 -1
View File
@@ -385,6 +385,78 @@ impl Page {
None => Some(doc_url.clone()),
}
}
async fn fetch_stylesheets(&mut self) {
let all_links = match &self.js {
Some(js) => {
js.with_dom(|dom| {
let link_ids = dom.query_selector_all("link[rel=\"stylesheet\"]").unwrap_or_default();
let mut links = Vec::new();
for lid in link_ids {
if let Some(node) = dom.get_node(lid) {
if let Some(href) = node.get_attribute("href") {
links.push(href.to_string());
}
}
}
links
}).unwrap_or_default()
}
None => {
tracing::info!("fetch_stylesheets: no js runtime");
return;
}
};
tracing::info!("fetch_stylesheets: found {} stylesheet links", all_links.len());
let document_base = self.resolve_base_url();
let mut fetch_tasks = Vec::new();
for href in all_links {
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
href.clone()
} else if let Some(base) = &document_base {
base.join(&href).map(|u| u.to_string()).unwrap_or_else(|_| href.clone())
} else {
href.clone()
};
tracing::info!("fetch_stylesheets: fetching {}", full_url);
let client = self.http_client.clone();
fetch_tasks.push(tokio::spawn(async move {
if let Ok(url) = url::Url::parse(&full_url) {
if let Ok(Ok(resp)) = tokio::time::timeout(tokio::time::Duration::from_secs(5), client.fetch(&url)).await {
if let Ok(css) = String::from_utf8(resp.body) {
return Some(css);
}
}
}
None
}));
}
let mut all_css = String::new();
for task in fetch_tasks {
if let Ok(Some(css)) = task.await {
tracing::info!("fetch_stylesheets: fetched {} bytes of CSS", css.len());
all_css.push_str(&css);
all_css.push('\n');
}
}
if !all_css.is_empty() {
tracing::info!("fetch_stylesheets: injecting {} bytes of CSS", all_css.len());
if let Some(js) = &mut self.js {
let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$");
let code = format!(
"var style = document.createElement('style'); style.textContent = `{}`; document.head.appendChild(style);",
escaped_css
);
let _ = js.execute_script("<fetch_stylesheets>", &code);
}
}
}
async fn execute_scripts(&mut self) {
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
@@ -1180,6 +1252,7 @@ impl Page {
// listeners never registered, frameworks never bootstrapped,
// page.click() handlers were no-ops. Now scripts run regardless
// of waitUntil and DCL means "DOM parsed AND scripts executed".
self.fetch_stylesheets().await;
self.execute_scripts().await;
self.lifecycle = LifecycleState::DomContentLoaded;
@@ -1286,7 +1359,11 @@ impl Page {
/// viewport is zero-sized.
#[cfg(feature = "render")]
pub fn screenshot(&self, viewport: (f32, f32)) -> Option<Vec<u8>> {
self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport)).flatten()
// Needed to resolve the relative image URLs ("logo.svg") that make up
// the overwhelming majority of real markup.
let base_url = self.resolve_base_url();
let base_url = base_url.as_ref().map(|u| u.as_str());
self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport, base_url)).flatten()
}
/// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty