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:
1 parent
ea486a8900
commit
8fa387faed
1 file changed
+172
-15
@@ -202,6 +202,116 @@ pub struct Page {
|
|||||||
pub stealth_client: Option<Arc<StealthHttpClient>>,
|
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 {
|
impl Page {
|
||||||
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
|
pub fn new(id: String, context: Arc<BrowserContext>) -> Self {
|
||||||
let http_client = context.http_client.clone();
|
let http_client = context.http_client.clone();
|
||||||
@@ -394,13 +504,30 @@ impl Page {
|
|||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
for lid in link_ids {
|
for lid in link_ids {
|
||||||
if let Some(node) = dom.get_node(lid) {
|
if let Some(node) = dom.get_node(lid) {
|
||||||
// Skip print-only stylesheets: a `<link media="print">`
|
// Skip disabled stylesheets: a `<link ... disabled>`
|
||||||
// must not apply on screen. Some sites (e.g. the
|
// does not apply until JS clears the attribute. Sites
|
||||||
// Guardian) ship a print sheet whose rules otherwise
|
// ship alternate themes this way (e.g. Google devsite's
|
||||||
// overrode the screen masthead/section styling.
|
// 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") {
|
if let Some(media) = node.get_attribute("media") {
|
||||||
let m = media.to_ascii_lowercase();
|
let compact: String = media.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
|
||||||
if m.contains("print") && !m.contains("screen") && !m.contains("all") {
|
let print_only = compact.contains("print") && !compact.contains("screen") && !compact.contains("all");
|
||||||
|
if print_only || compact.contains("prefers-color-scheme:dark") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,14 +562,7 @@ impl Page {
|
|||||||
|
|
||||||
let client = self.http_client.clone();
|
let client = self.http_client.clone();
|
||||||
fetch_tasks.push(tokio::spawn(async move {
|
fetch_tasks.push(tokio::spawn(async move {
|
||||||
if let Ok(url) = url::Url::parse(&full_url) {
|
fetch_css_with_imports(client, full_url, 0).await
|
||||||
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
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,7 +1985,9 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn truncate_never_splits_a_multibyte_char() {
|
fn truncate_never_splits_a_multibyte_char() {
|
||||||
@@ -1879,6 +2001,41 @@ mod tests {
|
|||||||
assert_eq!(truncate_on_char_boundary("short", 80), "short");
|
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]
|
#[test]
|
||||||
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
||||||
assert!(url_matches_cdp_pattern(
|
assert!(url_matches_cdp_pattern(
|
||||||
|
|||||||
Reference in new issue
Block a user