From 8fa387faede60578209f58c05129b8cc3a6d3490 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:56:28 +0000
Subject: [PATCH] render: respect stylesheet gating (disabled/media) and follow
@import
Skip 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.
---
crates/obscura-browser/src/page.rs | 187 ++++++++++++++++++++++++++---
1 file changed, 172 insertions(+), 15 deletions(-)
diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index a8a72b1..30ef124 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -202,6 +202,116 @@ pub struct Page {
pub stealth_client: Option>,
}
+/// 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,
+ url: String,
+ depth: u8,
+) -> std::pin::Pin> + 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) {
+ 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 {
+ 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) -> 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 ``
- // 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 ``
+ // 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 `` must not apply on screen (the
+ // Guardian ships a print sheet that otherwise
+ // overrode the screen masthead), and a `` 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(