From f7fec080adf26a6bfbd8dafa72d02223a5776537 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Sun, 26 Jul 2026 22:33:44 +0000
Subject: [PATCH] render: preserve stylesheet asset bases
---
crates/obscura-browser/src/page.rs | 145 ++++++++++++++++++++++++++++-
1 file changed, 140 insertions(+), 5 deletions(-)
diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index da31055..48f6a20 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -224,14 +224,14 @@ fn fetch_css_with_imports(
.await
.ok()?
.ok()?;
- let css = String::from_utf8(resp.body).ok()?;
+ let css = obscura_net::decode_non_html(&resp.body, resp.content_type());
// Stop recursing at the cap but keep this level's CSS as-is.
if depth >= 4 {
- return Some(css);
+ return Some(rebase_css_urls(&css, &parsed));
}
let (imports, stripped) = split_css_imports(&css);
if imports.is_empty() {
- return Some(css);
+ return Some(rebase_css_urls(&css, &parsed));
}
let mut out = String::new();
for imp in imports {
@@ -242,11 +242,121 @@ fn fetch_css_with_imports(
}
}
}
- out.push_str(&stripped);
+ out.push_str(&rebase_css_urls(&stripped, &parsed));
Some(out)
})
}
+/// Preserve the URL base of a fetched stylesheet after it is materialized as
+/// inline CSS. Relative `url(...)` values resolve against the stylesheet's
+/// URL in browsers, not the document URL; failing to rebase them drops common
+/// background, mask, cursor, and font assets from nested theme directories.
+fn rebase_css_urls(css: &str, base: &url::Url) -> String {
+ let mut out = String::with_capacity(css.len());
+ let mut index = 0usize;
+ while index < css.len() {
+ let rest = &css[index..];
+ if rest.starts_with("/*") {
+ if let Some(end) = rest[2..].find("*/") {
+ let length = end + 4;
+ out.push_str(&rest[..length]);
+ index += length;
+ } else {
+ out.push_str(rest);
+ break;
+ }
+ continue;
+ }
+ let Some(first) = rest.chars().next() else { break };
+ if first == '"' || first == '\'' {
+ let quote = first;
+ let mut escaped = false;
+ let mut length = quote.len_utf8();
+ for ch in rest[quote.len_utf8()..].chars() {
+ length += ch.len_utf8();
+ if escaped {
+ escaped = false;
+ } else if ch == '\\' {
+ escaped = true;
+ } else if ch == quote {
+ break;
+ }
+ }
+ out.push_str(&rest[..length]);
+ index += length;
+ continue;
+ }
+ let is_url = rest
+ .get(..4)
+ .map_or(false, |prefix| prefix.eq_ignore_ascii_case("url("));
+ if !is_url {
+ out.push(first);
+ index += first.len_utf8();
+ continue;
+ }
+
+ let mut quote = None;
+ let mut escaped = false;
+ let mut end = None;
+ for (offset, ch) in rest[4..].char_indices() {
+ if escaped {
+ escaped = false;
+ continue;
+ }
+ if ch == '\\' {
+ escaped = true;
+ continue;
+ }
+ match quote {
+ Some(open) if ch == open => quote = None,
+ Some(_) => {}
+ None if ch == '"' || ch == '\'' => quote = Some(ch),
+ None if ch == ')' => {
+ end = Some(4 + offset);
+ break;
+ }
+ None => {}
+ }
+ }
+ let Some(end) = end else {
+ out.push_str(rest);
+ break;
+ };
+ let raw = rest[4..end].trim();
+ let value = if raw.len() >= 2
+ && ((raw.starts_with('"') && raw.ends_with('"'))
+ || (raw.starts_with('\'') && raw.ends_with('\'')))
+ {
+ &raw[1..raw.len() - 1]
+ } else {
+ raw
+ };
+ let resolved = if value.is_empty()
+ || value.starts_with('#')
+ || value.contains("var(")
+ || url::Url::parse(value).is_ok()
+ {
+ None
+ } else {
+ base.join(value).ok().map(|url| url.to_string())
+ };
+ if let Some(resolved) = resolved {
+ out.push_str("url(\"");
+ for ch in resolved.chars() {
+ if ch == '\\' || ch == '"' {
+ out.push('\\');
+ }
+ out.push(ch);
+ }
+ out.push_str("\")");
+ } else {
+ out.push_str(&rest[..=end]);
+ }
+ index += end + 1;
+ }
+ 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
@@ -2173,7 +2283,7 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
- parse_import_url, should_restore_hydration_snapshot, split_css_imports,
+ parse_import_url, rebase_css_urls, should_restore_hydration_snapshot, split_css_imports,
truncate_on_char_boundary, url_matches_cdp_pattern, HydrationStats,
};
use obscura_dom::parse_html;
@@ -2225,6 +2335,31 @@ mod tests {
assert_eq!(stripped, css);
}
+ #[test]
+ fn stylesheet_asset_urls_keep_the_importing_sheets_base() {
+ let base = url::Url::parse("https://example.com/css/theme/app.css").unwrap();
+ let css = r#"
+ .hero { background:url("../img/hero.png") }
+ .icon { mask-image:URL('./icons/mark.svg') }
+ .data { background:url("data:image/svg+xml,") }
+ .fragment { mask:url(#shape) }
+ .copy::before { content:"url(../not-an-asset.png)" }
+ /* url(../not-an-asset-either.png) */
+ "#;
+ let rebased = rebase_css_urls(css, &base);
+
+ assert!(rebased.contains(
+ r#"url("https://example.com/css/img/hero.png")"#
+ ));
+ assert!(rebased.contains(
+ r#"url("https://example.com/css/theme/icons/mark.svg")"#
+ ));
+ assert!(rebased.contains(r#"url("data:image/svg+xml,")"#));
+ assert!(rebased.contains("url(#shape)"));
+ assert!(rebased.contains(r#"content:"url(../not-an-asset.png)""#));
+ assert!(rebased.contains("/* url(../not-an-asset-either.png) */"));
+ }
+
#[test]
fn stylesheet_rel_token_selector_includes_preloaded_stylesheets() {
let dom = parse_html(