render: respect stylesheet gating (disabled/media) and follow @import

Skip <link rel=stylesheet> sheets that are disabled or gated to
print/dark, and resolve @import chains inside fetched sheets so the
imported CSS reaches the cascade.

- disabled links (JS-toggled alternate themes) no longer paint pages
  dark under a light Chromium
- media="(prefers-color-scheme: dark)" link sheets are skipped
- @import url(...) is fetched recursively; Sphinx docs pull their layout
  from basic.css via classic.css and it was being dropped

Pixel fidelity vs headless Chromium: docs.python 95.5 to 10.2 percent,
web.dev 91.1 to 10.5, developer.chrome 88.0 to 18.9. No regressions on
the good-site set; obstacle course still 33/33.
This commit is contained in:
SGavrl committed 2026-07-16 11:56:28 +00:00
1 parent ea486a8900
commit 8fa387faed
1 file changed
+172 -15
+172 -15
View File
@@ -202,6 +202,116 @@ pub struct Page {
pub stealth_client: Option<Arc<StealthHttpClient>>,
}
/// Fetch an external stylesheet and inline any `@import`ed sheets it references,
/// recursively, so the imported CSS actually reaches the cascade. Sphinx docs
/// (and other older/theme-chained sites) ship the real layout in a base sheet
/// pulled in with `@import url("basic.css")` from the linked `classic.css`;
/// dropping it left the page effectively unstyled. Imports resolve against the
/// importing sheet's own URL and are inlined before its rules (CSS orders every
/// `@import` ahead of the sheet's other statements). A depth cap guards against
/// import cycles.
fn fetch_css_with_imports(
client: Arc<ObscuraHttpClient>,
url: String,
depth: u8,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>> {
Box::pin(async move {
let parsed = url::Url::parse(&url).ok()?;
let resp = tokio::time::timeout(tokio::time::Duration::from_secs(5), client.fetch(&parsed))
.await
.ok()?
.ok()?;
let css = String::from_utf8(resp.body).ok()?;
// Stop recursing at the cap but keep this level's CSS as-is.
if depth >= 4 {
return Some(css);
}
let (imports, stripped) = split_css_imports(&css);
if imports.is_empty() {
return Some(css);
}
let mut out = String::new();
for imp in imports {
if let Ok(iu) = parsed.join(&imp) {
if let Some(sub) = fetch_css_with_imports(client.clone(), iu.to_string(), depth + 1).await {
out.push_str(&sub);
out.push('\n');
}
}
}
out.push_str(&stripped);
Some(out)
})
}
/// Pull leading `@import` rules out of a stylesheet. Returns each import target
/// URL (skipping print-only and `prefers-color-scheme: dark` conditional
/// imports, which must not apply in our light desktop context) plus the CSS
/// with those `@import` statements removed. Handles `@import "x.css";`,
/// `@import url("x.css");`, `@import url(x.css);` and an optional trailing
/// media query.
fn split_css_imports(css: &str) -> (Vec<String>, String) {
let mut urls = Vec::new();
let mut stripped = String::with_capacity(css.len());
let mut rest = css;
loop {
let Some(pos) = rest.find("@import") else {
stripped.push_str(rest);
break;
};
// Real sheets place `@import` at the top (after an optional @charset), so
// scanning for it anywhere is safe in practice and tolerates minified
// whitespace. Text before this match carries through unchanged.
stripped.push_str(&rest[..pos]);
let after = &rest[pos + "@import".len()..];
let Some(semi) = after.find(';') else {
// Malformed; keep the remainder verbatim.
stripped.push_str(&rest[pos..]);
break;
};
let stmt = &after[..semi];
if let Some(target) = parse_import_url(stmt) {
urls.push(target);
} else {
// Could not parse a URL; preserve the statement so we don't lose it.
stripped.push_str("@import");
stripped.push_str(&after[..=semi]);
}
rest = &after[semi + 1..];
}
(urls, stripped)
}
/// Extract the URL from an `@import` statement body (the text between `@import`
/// and `;`), or `None` when the import is media-gated to print / dark and must
/// be skipped.
fn parse_import_url(stmt: &str) -> Option<String> {
let s = stmt.trim();
let is_url_fn = s.len() >= 4 && s[..4].eq_ignore_ascii_case("url(");
let (url, media) = if is_url_fn {
let rest = &s[4..];
let end = rest.find(')')?;
let inner = rest[..end].trim().trim_matches(|c| c == '"' || c == '\'');
(inner.to_string(), rest[end + 1..].trim())
} else {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[1..];
let end = rest.find(quote)?;
(rest[..end].to_string(), rest[end + 1..].trim())
};
if !media.is_empty() {
let compact: String = media.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
let print_only = compact.contains("print") && !compact.contains("screen") && !compact.contains("all");
if print_only || compact.contains("prefers-color-scheme:dark") {
return None;
}
}
if url.is_empty() {
return None;
}
Some(url)
}
impl Page {
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
let http_client = context.http_client.clone();
@@ -394,13 +504,30 @@ impl Page {
let mut links = Vec::new();
for lid in link_ids {
if let Some(node) = dom.get_node(lid) {
// Skip print-only stylesheets: a `<link media="print">`
// must not apply on screen. Some sites (e.g. the
// Guardian) ship a print sheet whose rules otherwise
// overrode the screen masthead/section styling.
// Skip disabled stylesheets: a `<link ... disabled>`
// does not apply until JS clears the attribute. Sites
// ship alternate themes this way (e.g. Google devsite's
// dark-theme.css is linked `disabled` and toggled by a
// theme switcher); applying it painted whole pages dark
// when Chromium renders them light. `disabled` is a
// boolean attribute, so any presence means off.
if node.get_attribute("disabled").is_some() {
continue;
}
// Respect the link's `media` gate. A `<link
// media="print">` must not apply on screen (the
// Guardian ships a print sheet that otherwise
// overrode the screen masthead), and a `<link
// media="(prefers-color-scheme: dark)">` must not
// apply in our light desktop context (docs.python and
// other Sphinx sites link pydoctheme_dark.css this way;
// injecting it painted the page dark under a light
// Chromium). We render the default light context, so
// skip sheets gated to print-only or dark preference.
if let Some(media) = node.get_attribute("media") {
let m = media.to_ascii_lowercase();
if m.contains("print") && !m.contains("screen") && !m.contains("all") {
let compact: String = media.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
let print_only = compact.contains("print") && !compact.contains("screen") && !compact.contains("all");
if print_only || compact.contains("prefers-color-scheme:dark") {
continue;
}
}
@@ -435,14 +562,7 @@ impl Page {
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
fetch_css_with_imports(client, full_url, 0).await
}));
}
@@ -1865,7 +1985,9 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{truncate_on_char_boundary, url_matches_cdp_pattern};
use super::{
parse_import_url, split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern,
};
#[test]
fn truncate_never_splits_a_multibyte_char() {
@@ -1879,6 +2001,41 @@ mod tests {
assert_eq!(truncate_on_char_boundary("short", 80), "short");
}
#[test]
fn parse_import_url_extracts_url_forms() {
assert_eq!(parse_import_url(" url(\"basic.css\")").as_deref(), Some("basic.css"));
assert_eq!(parse_import_url(" url(basic.css)").as_deref(), Some("basic.css"));
assert_eq!(parse_import_url(" \"basic.css\"").as_deref(), Some("basic.css"));
assert_eq!(parse_import_url(" 'theme.css'").as_deref(), Some("theme.css"));
assert_eq!(parse_import_url(" URL('x.css')").as_deref(), Some("x.css"));
}
#[test]
fn parse_import_url_skips_print_and_dark_media() {
assert_eq!(parse_import_url("url(\"p.css\") print"), None);
assert_eq!(parse_import_url("url(\"d.css\") (prefers-color-scheme: dark)"), None);
// screen / all and light preference still apply.
assert_eq!(parse_import_url("url(\"s.css\") screen").as_deref(), Some("s.css"));
assert_eq!(parse_import_url("url(\"a.css\") print, screen").as_deref(), Some("a.css"));
}
#[test]
fn split_css_imports_pulls_imports_and_strips_them() {
let css = "@import url(\"basic.css\");\nbody { color: red; }";
let (imports, stripped) = split_css_imports(css);
assert_eq!(imports, vec!["basic.css".to_string()]);
assert!(!stripped.contains("@import"));
assert!(stripped.contains("body { color: red; }"));
}
#[test]
fn split_css_imports_leaves_import_free_css_untouched() {
let css = "body { color: red; }";
let (imports, stripped) = split_css_imports(css);
assert!(imports.is_empty());
assert_eq!(stripped, css);
}
#[test]
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
assert!(url_matches_cdp_pattern(