diff --git a/Cargo.lock b/Cargo.lock index 00726e8..5511d8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,6 +1870,7 @@ dependencies = [ "anyhow", "base64", "futures", + "image", "obscura-dom", "obscura-js", "obscura-net", @@ -1957,6 +1958,7 @@ dependencies = [ "obscura-net", "obscura-render", "pbkdf2", + "psl", "reqwest", "serde", "serde_json", @@ -2358,6 +2360,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.223" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0dedad316e05de220cbf3fd8bfb69f3df8755dde2d7d479211827b595168a93" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pxfm" version = "0.1.29" diff --git a/crates/obscura-browser/Cargo.toml b/crates/obscura-browser/Cargo.toml index 8abcc0d..ab87de7 100644 --- a/crates/obscura-browser/Cargo.toml +++ b/crates/obscura-browser/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [features] default = [] stealth = ["obscura-net/stealth", "obscura-js/stealth"] -render = ["obscura-js/render"] +render = ["obscura-js/render", "dep:image"] [dependencies] obscura-dom = { workspace = true } @@ -20,3 +20,4 @@ thiserror = { workspace = true } anyhow = { workspace = true } serde_json = { workspace = true } base64 = { workspace = true } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true } diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs index c715344..02e1266 100644 --- a/crates/obscura-browser/src/lib.rs +++ b/crates/obscura-browser/src/lib.rs @@ -1,12 +1,18 @@ -pub mod page; pub mod context; pub mod lifecycle; +pub mod page; +#[cfg(feature = "render")] +pub mod pdf; pub mod profiles; -pub use page::{NetworkEvent, Page, PageError}; pub use context::BrowserContext; pub use lifecycle::{LifecycleState, WaitUntil}; pub use obscura_js::HTML_TO_MARKDOWN_JS; +#[cfg(feature = "render")] +pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion}; +pub use page::{NetworkEvent, Page, PageError}; +#[cfg(feature = "render")] +pub use pdf::{RasterPdfError, RasterPdfOptions}; // Re-exported so the embeddable `obscura` crate (which depends on obscura-browser, // not obscura-js) can surface the interception channel types. pub use obscura_js::ops::{InterceptResolution, InterceptedRequest}; diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 43f0ce7..f9437d2 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -111,11 +111,15 @@ fn cross_scheme_to_file(from: &str, to: &str) -> bool { /// Real Chrome allows data: subresources by default; Instagram and most /// Meta properties depend on this for their inline bootstrap scripts. fn subresource_allowed(page_url: Option<&Url>, resource: &str) -> bool { - let Ok(target) = Url::parse(resource) else { return false }; + let Ok(target) = Url::parse(resource) else { + return false; + }; let scheme = target.scheme().to_ascii_lowercase(); match scheme.as_str() { "http" | "https" | "data" => true, - "file" => page_url.map(|u| u.scheme().eq_ignore_ascii_case("file")).unwrap_or(false), + "file" => page_url + .map(|u| u.scheme().eq_ignore_ascii_case("file")) + .unwrap_or(false), _ => false, } } @@ -206,6 +210,10 @@ pub struct Page { /// CSS viewport used by responsive page JavaScript and CDP screenshots. /// The physical `screen` fingerprint remains independent. pub viewport: (f32, f32), + /// Output device pixels per CSS pixel for CDP surface capture. Layout and + /// CSSOM stay in CSS pixels; Emulation.setDeviceMetricsOverride owns this + /// independent raster scale. + pub device_scale_factor: f32, /// WHATWG canonical name of the current document's character encoding /// (e.g. "UTF-8", "EUC-JP"), detected when the response body is decoded. /// Exposed to JS as `document.characterSet` and used for the URL query @@ -280,9 +288,7 @@ fn materialize_stylesheet_graph( continue; }; let (import_key, _) = canonical_stylesheet_url(import_url); - if let Some(imported) = - materialize_stylesheet_graph(&import_key, sheets, aliases, active) - { + if let Some(imported) = materialize_stylesheet_graph(&import_key, sheets, aliases, active) { output.push_str(&imported); output.push('\n'); } @@ -312,7 +318,9 @@ fn rebase_css_urls(css: &str, base: &url::Url) -> String { } continue; } - let Some(first) = rest.chars().next() else { break }; + let Some(first) = rest.chars().next() else { + break; + }; if first == '"' || first == '\'' { let quote = first; let mut escaped = false; @@ -418,7 +426,9 @@ fn css_resource_urls(css: &str, base: &url::Url) -> Vec { } continue; } - let Some(first) = rest.chars().next() else { break }; + let Some(first) = rest.chars().next() else { + break; + }; if first == '"' || first == '\'' { let quote = first; let mut escaped = false; @@ -436,7 +446,10 @@ fn css_resource_urls(css: &str, base: &url::Url) -> Vec { index += length; continue; } - if !rest.get(..4).is_some_and(|prefix| prefix.eq_ignore_ascii_case("url(")) { + if !rest + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("url(")) + { index += first.len_utf8(); continue; } @@ -558,8 +571,13 @@ fn parse_import_url(stmt: &str) -> Option { (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"); + 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; } @@ -690,6 +708,7 @@ impl Page { title: String::new(), referrer: String::new(), viewport: (1280.0, 720.0), + device_scale_factor: 1.0, encoding: "UTF-8".to_string(), history: Vec::new(), history_index: 0, @@ -742,6 +761,26 @@ impl Page { } } + /// Set the screenshot surface density without changing CSS layout. CDP + /// uses zero to disable its override, which restores the native 1x surface + /// in Obscura's headless-only model. + pub fn set_device_scale_factor(&mut self, device_scale_factor: f32) { + if !device_scale_factor.is_finite() || device_scale_factor < 0.0 { + return; + } + self.device_scale_factor = if device_scale_factor == 0.0 { + 1.0 + } else { + device_scale_factor + }; + if let Some(js) = &mut self.js { + let _ = js.execute_script( + "", + &format!("globalThis.devicePixelRatio={};", self.device_scale_factor), + ); + } + } + async fn do_fetch(&self, url: &Url) -> Result { #[cfg(feature = "stealth")] if let Some(ref stealth) = self.stealth_client { @@ -839,6 +878,10 @@ impl Page { } rt.run_page_init(); + let _ = rt.execute_script( + "", + &format!("globalThis.devicePixelRatio={};", self.device_scale_factor), + ); self.js = Some(rt); } @@ -849,21 +892,20 @@ impl Page { fn resolve_base_url(&self) -> Option { let doc_url = self.url.as_ref()?; let base_href: Option = self.js.as_ref().and_then(|js| { - js.with_dom(|dom| { - match dom.query_selector("base[href]") { - Ok(Some(nid)) => { - dom.get_node(nid).and_then(|n| n.get_attribute("href").map(|s| s.to_string())) - } - _ => None, - } - }).flatten() + js.with_dom(|dom| match dom.query_selector("base[href]") { + Ok(Some(nid)) => dom + .get_node(nid) + .and_then(|n| n.get_attribute("href").map(|s| s.to_string())), + _ => None, + }) + .flatten() }); match base_href { Some(href) => doc_url.join(&href).ok(), None => Some(doc_url.clone()), } } - + async fn fetch_stylesheets(&mut self) -> Vec<(usize, String)> { let all_links = match &self.js { Some(js) => js.with_dom(linked_stylesheet_requests).unwrap_or_default(), @@ -873,12 +915,17 @@ impl Page { } }; - tracing::info!("fetch_stylesheets: found {} stylesheet links", all_links.len()); + tracing::info!( + "fetch_stylesheets: found {} stylesheet links", + all_links.len() + ); let Some(document_url) = self.url.clone() else { return Vec::new(); }; - let document_base = self.resolve_base_url().unwrap_or_else(|| document_url.clone()); + let document_base = self + .resolve_base_url() + .unwrap_or_else(|| document_url.clone()); let mut roots = Vec::new(); let mut scheduled = std::collections::HashSet::new(); let mut pending = Vec::new(); @@ -917,8 +964,8 @@ impl Page { let callbacks = self.callbacks.clone(); let initiator = document_url.clone(); use futures::StreamExt as _; - let results: Vec<_> = futures::stream::iter(batch.into_iter().map( - |(key, requested_url, depth)| { + let results: Vec<_> = + futures::stream::iter(batch.into_iter().map(|(key, requested_url, depth)| { let client = client.clone(); #[cfg(feature = "stealth")] let stealth_client = stealth_client.clone(); @@ -955,21 +1002,16 @@ impl Page { .await; (key, requested_url, depth, result) } - }, - )) - .buffered(16) - .collect() - .await; + })) + .buffered(16) + .collect() + .await; for (key, requested_url, depth, result) in results { let response = match result { Ok(response) => response, Err(error) => { - tracing::debug!( - "Failed to fetch stylesheet {}: {}", - requested_url, - error - ); + tracing::debug!("Failed to fetch stylesheet {}: {}", requested_url, error); continue; } }; @@ -989,10 +1031,7 @@ impl Page { aliases.insert(key, existing); continue; } - let css = obscura_net::decode_non_html( - &response.body, - response.content_type(), - ); + let css = obscura_net::decode_non_html(&response.body, response.content_type()); let (imports, rules) = split_css_imports(&css); let imports = if depth < MAX_STYLESHEET_IMPORT_DEPTH { imports @@ -1060,7 +1099,10 @@ impl Page { async fn execute_scripts_with_module_budget(&mut self, module_budget_override: Option) { let scripts_started = std::time::Instant::now(); - tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); + tracing::info!( + "execute_scripts called, js runtime exists: {}", + self.js.is_some() + ); // Soft deadline on the entire script-execution phase. Heavy SPAs // (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial // fetch + execute loop can blow past a Puppeteer/Playwright goto @@ -1078,8 +1120,8 @@ impl Page { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(30_000); - let script_deadline = tokio::time::Instant::now() - + tokio::time::Duration::from_millis(script_deadline_ms); + let script_deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_millis(script_deadline_ms); // Hard backstop over the WHOLE script-execution phase. Inline scripts // run back-to-back with no await between them, so neither the soft @@ -1129,9 +1171,8 @@ impl Page { if name.local.as_ref() == "base" && !found_base { if let Some(href) = node.get_attribute("href") { found_base = true; - if let Some(resolved) = active_base - .as_ref() - .and_then(|base| base.join(href).ok()) + if let Some(resolved) = + active_base.as_ref().and_then(|base| base.join(href).ok()) { active_base = Some(resolved); } @@ -1193,7 +1234,8 @@ impl Page { } } scripts - }).unwrap_or_default() + }) + .unwrap_or_default() } None => return, }; @@ -1222,7 +1264,8 @@ impl Page { continue; } if let Some(src_url) = &script.src { - let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") { + let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") + { src_url.clone() } else { url::Url::parse(&script.base_url) @@ -1255,46 +1298,50 @@ impl Page { let client = self.http_client.clone(); let page_callbacks = self.callbacks.clone(); - let fetch_futures: Vec<_> = fetch_tasks.iter().map(|(idx, url)| { - let client = client.clone(); - let cbs = page_callbacks.clone(); - let url = url.clone(); - let idx = *idx; - async move { - let parsed = Url::parse(&url).unwrap_or_else(|_| Url::parse("about:blank").unwrap()); - if parsed.scheme() == "data" { - // data: URIs are inline; decode locally, no network fetch. - // Instagram and other Meta properties serve their bootstrap - // as "#, ); let encoded = base64::engine::general_purpose::STANDARD.encode(html); - page.url = Some( - url::Url::parse(&format!("data:text/html;base64,{encoded}")) - .expect("data URL"), - ); + page.url = + Some(url::Url::parse(&format!("data:text/html;base64,{encoded}")).expect("data URL")); page } @@ -3248,9 +3515,11 @@ mod tests { let length = stream.read(&mut request).unwrap(); let request = String::from_utf8_lossy(&request[..length]); let path = request - .lines().next() + .lines() + .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) - .unwrap_or("/").to_string(); + .unwrap_or("/") + .to_string(); request_tx.send(path.clone()).unwrap(); let (status, body) = match path.as_str() { "/app/before.js" => ("200 OK", "export const value = 'before-first-module';"), @@ -3288,7 +3557,12 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn suspend_resume_preserves_document_script_start_state() { let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( - "script-state-suspend".to_string(), None, false, None, None, true, + "script-state-suspend".to_string(), + None, + false, + None, + None, + true, )); let mut page = super::Page::new("script-state-suspend".to_string(), context); page.url = Some(url::Url::parse("http://example.com/suspend.html").unwrap()); @@ -3359,7 +3633,12 @@ mod tests { #[test] fn new_document_does_not_inherit_suspended_script_ids() { let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( - "script-state-navigation".to_string(), None, false, None, None, true, + "script-state-navigation".to_string(), + None, + false, + None, + None, + true, )); let mut page = super::Page::new("script-state-navigation".to_string(), context); page.url = Some(url::Url::parse("http://example.com/old.html").unwrap()); @@ -3394,7 +3673,12 @@ mod tests { fn import_map_test_page(name: &str, base: &str, html: &str) -> super::Page { let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( - name.to_string(), None, false, None, None, true, + name.to_string(), + None, + false, + None, + None, + true, )); let mut page = super::Page::new(name.to_string(), context); page.url = Some(url::Url::parse(&format!("{}/app/index.html", base)).unwrap()); @@ -3404,7 +3688,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn module_graph_and_evaluation_share_one_absolute_budget() { + async fn module_graph_and_evaluation_share_one_active_budget() { use std::io::{Read as _, Write as _}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3424,7 +3708,7 @@ mod tests { // Spend part of the module's allowance loading its graph. The // synchronous top-level work then fits in a freshly reset budget, - // but cannot fit in the one absolute load+evaluation budget. + // but cannot fit in the shared active load+evaluation budget. std::thread::sleep(std::time::Duration::from_millis(100)); let body = "export const delayed = true;"; let response = format!( @@ -3470,30 +3754,101 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn queued_module_does_not_spend_its_budget_waiting_for_deferred_script() { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 2048]; + let length = stream.read(&mut request).unwrap(); + let path = String::from_utf8_lossy(&request[..length]) + .lines() + .next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let body = if path.ends_with("deferred.js") { + "const until=Date.now()+500;while(Date.now() + + + "#, + ); + page.execute_scripts_with_module_budget(Some(300)).await; + + assert_eq!( + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__queued_module_completed === true") + .unwrap(), + serde_json::json!(true), + "queue latency must not consume a module's active-work budget", + ); + } + #[tokio::test(flavor = "current_thread")] async fn parser_import_map_before_first_module_controls_resolution() { let (base, requests) = spawn_parser_import_map_server(1); - let mut page = import_map_test_page("import-map-order", &base, r#" + let mut page = import_map_test_page( + "import-map-order", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__parser_import_map_value").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__parser_import_map_value") + .unwrap(), serde_json::json!("before-first-module"), ); - assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js"); + assert_eq!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/before.js" + ); } #[tokio::test(flavor = "current_thread")] async fn later_import_map_adds_unrelated_rule_without_rebinding_resolved_rule() { let (base, requests) = spawn_parser_import_map_server(2); - let mut page = import_map_test_page("multiple-import-map-order", &base, r#" + let mut page = import_map_test_page( + "multiple-import-map-order", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; let js = page.js.as_mut().unwrap(); - assert_eq!(js.evaluate("globalThis.__first_map_value").unwrap(), serde_json::json!("before-first-module")); - assert_eq!(js.evaluate("globalThis.__later_map_values").unwrap(), serde_json::json!(["before-first-module", "later-map"])); + assert_eq!( + js.evaluate("globalThis.__first_map_value").unwrap(), + serde_json::json!("before-first-module") + ); + assert_eq!( + js.evaluate("globalThis.__later_map_values").unwrap(), + serde_json::json!(["before-first-module", "later-map"]) + ); let paths = (0..2) - .map(|_| requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap()) + .map(|_| { + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap() + }) .collect::>(); assert!(paths.contains(&"/app/before.js".to_string()), "{paths:?}"); assert!(paths.contains(&"/app/later.js".to_string()), "{paths:?}"); @@ -3522,17 +3888,25 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn classic_dynamic_import_does_not_see_a_later_parser_import_map() { let (base, _requests) = spawn_parser_import_map_server(1); - let mut page = import_map_test_page("classic-before-import-map", &base, r#" + let mut page = import_map_test_page( + "classic-before-import-map", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__classic_before_map").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__classic_before_map") + .unwrap(), serde_json::json!("rejected"), ); } @@ -3540,23 +3914,39 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn ready_async_classic_script_runs_before_a_later_parser_import_map() { let (base, requests) = spawn_parser_import_map_server(2); - let mut page = import_map_test_page("async-classic-before-map", &base, r#" + let mut page = import_map_test_page( + "async-classic-before-map", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__async_before_map").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__async_before_map") + .unwrap(), serde_json::json!("rejected"), ); - assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/async.js"); + assert_eq!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/async.js" + ); assert!(requests.try_recv().is_err()); } #[tokio::test(flavor = "current_thread")] async fn dynamically_inserted_import_map_controls_later_dynamic_import() { let (base, requests) = spawn_parser_import_map_server(1); - let mut page = import_map_test_page("dynamic-import-map", &base, r#" + let mut page = import_map_test_page( + "dynamic-import-map", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_value").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__dynamic_map_value") + .unwrap(), serde_json::json!("later-map"), ); - assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js"); + assert_eq!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/later.js" + ); } #[tokio::test(flavor = "current_thread")] async fn dynamic_import_map_uses_live_document_base_at_insertion() { let (base, requests) = spawn_parser_import_map_server(1); - let mut page = import_map_test_page("dynamic-import-map-base", &base, r#" + let mut page = import_map_test_page( + "dynamic-import-map-base", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_base").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__dynamic_map_base") + .unwrap(), serde_json::json!("later-map"), ); - assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js"); + assert_eq!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/later.js" + ); } #[tokio::test(flavor = "current_thread")] async fn later_base_element_does_not_rebase_an_earlier_import_map() { let (base, requests) = spawn_parser_import_map_server(1); - let mut page = import_map_test_page("temporal-import-map-base", &base, r#" + let mut page = import_map_test_page( + "temporal-import-map-base", + &base, + r#" - "#); + "#, + ); page.execute_scripts().await; assert_eq!( - page.js.as_mut().unwrap().evaluate("globalThis.__temporal_base_value").unwrap(), + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__temporal_base_value") + .unwrap(), serde_json::json!("before-first-module"), ); - assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js"); + assert_eq!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), + "/app/before.js" + ); } #[cfg(feature = "render")] @@ -3633,7 +4059,10 @@ mod tests { let mut request = [0u8; 2048]; let read = stream.read(&mut request).unwrap_or(0); let first = String::from_utf8_lossy(&request[..read]) - .lines().next().unwrap_or_default().to_string(); + .lines() + .next() + .unwrap_or_default() + .to_string(); seen_tx.send(first).unwrap(); let body = br##""##; let response = format!( @@ -3652,7 +4081,12 @@ mod tests { }); let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( - "render-prefetch".to_string(), None, false, None, None, true, + "render-prefetch".to_string(), + None, + false, + None, + None, + true, )); let mut page = super::Page::new("render-prefetch".to_string(), context); page.set_viewport((100.0, 80.0)); @@ -3676,11 +4110,69 @@ mod tests { .unwrap() .starts_with("GET /asset.svg ")); assert!( - seen_rx.recv_timeout(std::time::Duration::from_millis(200)).is_err(), + seen_rx + .recv_timeout(std::time::Duration::from_millis(200)) + .is_err(), "capture must not open a second synchronous renderer request" ); } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn render_resource_deadline_does_not_negative_cache_cancelled_requests() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request); + std::thread::sleep(std::time::Duration::from_millis(100)); + let body = br##""##; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body); + }); + + let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( + "render-deadline".to_string(), + None, + false, + None, + None, + true, + )); + let mut page = super::Page::new("render-deadline".to_string(), context); + page.set_viewport((100.0, 80.0)); + let page_url = format!("http://{address}/page"); + let asset_url = format!("http://{address}/slow.svg"); + let dom = parse_html(&format!( + r#""# + )); + let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new(); + runtime.set_dom(dom); + runtime.set_url(&page_url); + runtime.set_viewport(100.0, 80.0); + runtime.run_page_init(); + page.js = Some(runtime); + page.url = Some(url::Url::parse(&page_url).unwrap()); + + assert_eq!(page.prepare_screenshot_resources(5).await, 0); + assert!( + !page + .js + .as_ref() + .unwrap() + .render_resource_is_known(&asset_url), + "a deadline-cancelled request must remain retryable" + ); + } + #[cfg(feature = "render")] #[test] fn page_screenshot_uses_the_live_window_scroll_offset() { @@ -3708,12 +4200,15 @@ mod tests { page.evaluate( "return (document.getElementById('second').scrollIntoView(), window.scrollY)" ) - .as_f64(), + .as_f64(), Some(80.0) ); let after = page.screenshot(page.viewport).expect("scrolled screenshot"); - assert_ne!(before, after, "Page screenshot must paint the scrolled viewport"); + assert_ne!( + before, after, + "Page screenshot must paint the scrolled viewport" + ); assert_eq!( page.js.as_ref().expect("runtime").scroll_offset(), (0.0, 80.0) @@ -3734,20 +4229,41 @@ mod tests { #[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(\"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); + 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")); + 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] @@ -3780,12 +4296,8 @@ mod tests { "#; 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("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)""#)); @@ -3859,9 +4371,7 @@ mod tests { .expect("valid selector"); ( dom.get_node(link) - .and_then(|node| { - node.get_attribute("data-loaded").map(str::to_owned) - }), + .and_then(|node| node.get_attribute("data-loaded").map(str::to_owned)), styles.first().map(|&nid| dom.text_content(nid)), ) }) @@ -3905,9 +4415,7 @@ mod tests { .expect("print link"); ( dom.get_node(link) - .and_then(|node| { - node.get_attribute("data-loaded").map(str::to_owned) - }), + .and_then(|node| node.get_attribute("data-loaded").map(str::to_owned)), dom.query_selector_all("style[data-obscura-external-stylesheets]") .expect("valid selector") .len(), @@ -3920,10 +4428,7 @@ mod tests { Some("yes"), "print link still fires load" ); - assert_eq!( - state.1, 0, - "print-only CSS must stay out of screen cascade" - ); + assert_eq!(state.1, 0, "print-only CSS must stay out of screen cascade"); } #[test] @@ -3975,7 +4480,9 @@ mod tests { let mut page = client_replacement_page("parser-client-replacement", false); let target = page.url_string(); - page.navigate(&target).await.expect("navigate replacement page"); + page.navigate(&target) + .await + .expect("navigate replacement page"); assert_client_replacement_survived(&mut page); } @@ -3984,7 +4491,9 @@ mod tests { async fn timer_body_replacement_survives_settle() { let mut page = client_replacement_page("timer-client-replacement", true); let target = page.url_string(); - page.navigate(&target).await.expect("navigate deferred replacement page"); + page.navigate(&target) + .await + .expect("navigate deferred replacement page"); let before_timer = page .js diff --git a/crates/obscura-browser/src/pdf.rs b/crates/obscura-browser/src/pdf.rs new file mode 100644 index 0000000..675fef1 --- /dev/null +++ b/crates/obscura-browser/src/pdf.rs @@ -0,0 +1,556 @@ +//! Bounded raster-backed PDF export over the retained document-space painter. +//! +//! This deliberately does not claim CSS paged-media support. It preserves the +//! screen layout, fits the full document width into the printable area, and +//! slices that immutable layout vertically across PDF pages. + +use std::io; + +use image::ImageEncoder as _; +use obscura_js::CaptureRegion; + +use crate::Page; + +const POINTS_PER_INCH: f32 = 72.0; +const MAX_PAPER_INCHES: f32 = 200.0; +const MAX_PDF_PAGES: usize = 250; +const MAX_PDF_PAGE_PIXELS: u64 = 16 * 1024 * 1024; +const MAX_PDF_TOTAL_RASTER_PIXELS: u64 = 64 * 1024 * 1024; +const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RasterPdfOptions { + pub landscape: bool, + pub paper_width_in: f32, + pub paper_height_in: f32, + pub margin_top_in: f32, + pub margin_bottom_in: f32, + pub margin_left_in: f32, + pub margin_right_in: f32, +} + +impl Default for RasterPdfOptions { + fn default() -> Self { + Self { + landscape: false, + paper_width_in: 8.5, + paper_height_in: 11.0, + // CDP's defaults are one centimetre. + margin_top_in: 0.3937, + margin_bottom_in: 0.3937, + margin_left_in: 0.3937, + margin_right_in: 0.3937, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RasterPdfError { + #[error("PDF paper dimensions must be finite and between 0 and 200 inches")] + InvalidPaperSize, + #[error("PDF margins must be finite, non-negative, and leave a printable area")] + InvalidMargins, + #[error("the page has no retained renderable document")] + NoRenderableDocument, + #[error("PDF pagination would exceed the {0}-page safety limit")] + TooManyPages(usize), + #[error("PDF raster work would exceed the bounded page or document pixel budget")] + RasterWorkLimitExceeded, + #[error("document-space PDF capture failed: {0}")] + CaptureFailed(String), + #[error("PDF raster image decoding failed: {0}")] + ImageDecode(String), + #[error("PDF JPEG encoding failed: {0}")] + ImageEncode(String), + #[error("encoded PDF would exceed the 64 MiB safety limit")] + OutputLimitExceeded, +} + +#[derive(Debug)] +struct RasterPage { + rgb: image::RgbImage, + draw_width_pt: f32, + draw_height_pt: f32, + #[cfg(test)] + _lifetime_probe: Option>, +} + +#[derive(Clone, Copy, Debug)] +struct PaginationPlan { + points_per_css_pixel: f32, + css_page_height: f32, + page_count: usize, +} + +impl RasterPdfOptions { + fn page_geometry(self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> { + let values = [self.paper_width_in, self.paper_height_in]; + if values + .iter() + .any(|value| !value.is_finite() || *value <= 0.0 || *value > MAX_PAPER_INCHES) + { + return Err(RasterPdfError::InvalidPaperSize); + } + let margins = [ + self.margin_top_in, + self.margin_bottom_in, + self.margin_left_in, + self.margin_right_in, + ]; + if margins + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + return Err(RasterPdfError::InvalidMargins); + } + let (paper_width_in, paper_height_in) = if self.landscape { + (self.paper_height_in, self.paper_width_in) + } else { + (self.paper_width_in, self.paper_height_in) + }; + let page_width = paper_width_in * POINTS_PER_INCH; + let page_height = paper_height_in * POINTS_PER_INCH; + let left = self.margin_left_in * POINTS_PER_INCH; + let bottom = self.margin_bottom_in * POINTS_PER_INCH; + let printable_width = + page_width - (self.margin_left_in + self.margin_right_in) * POINTS_PER_INCH; + let printable_height = + page_height - (self.margin_top_in + self.margin_bottom_in) * POINTS_PER_INCH; + if printable_width <= 0.0 || printable_height <= 0.0 { + return Err(RasterPdfError::InvalidMargins); + } + Ok(( + page_width, + page_height, + printable_width, + printable_height, + left, + bottom, + )) + } +} + +fn pagination_plan( + content_width: f32, + content_height: f32, + printable_width: f32, + printable_height: f32, +) -> Result { + let points_per_css_pixel = printable_width / content_width; + let css_page_height = printable_height / points_per_css_pixel; + if !points_per_css_pixel.is_finite() + || points_per_css_pixel <= 0.0 + || !css_page_height.is_finite() + || css_page_height <= 0.0 + { + return Err(RasterPdfError::RasterWorkLimitExceeded); + } + + let page_count_value = (content_height / css_page_height).ceil().max(1.0); + if !page_count_value.is_finite() || page_count_value > MAX_PDF_PAGES as f32 { + return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES)); + } + let page_count = page_count_value as usize; + let pixel_width = content_width.ceil(); + if !pixel_width.is_finite() + || pixel_width <= 0.0 + || pixel_width > obscura_js::MAX_CAPTURE_DIMENSION as f32 + { + return Err(RasterPdfError::RasterWorkLimitExceeded); + } + let pixel_width = pixel_width as u64; + let mut total_pixels = 0u64; + for page_index in 0..page_count { + let y = page_index as f32 * css_page_height; + let slice_height = (content_height - y).min(css_page_height).ceil(); + if !slice_height.is_finite() + || slice_height <= 0.0 + || slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32 + { + return Err(RasterPdfError::RasterWorkLimitExceeded); + } + let page_pixels = pixel_width + .checked_mul(slice_height as u64) + .ok_or(RasterPdfError::RasterWorkLimitExceeded)?; + if page_pixels > MAX_PDF_PAGE_PIXELS { + return Err(RasterPdfError::RasterWorkLimitExceeded); + } + total_pixels = total_pixels + .checked_add(page_pixels) + .ok_or(RasterPdfError::RasterWorkLimitExceeded)?; + if total_pixels > MAX_PDF_TOTAL_RASTER_PIXELS { + return Err(RasterPdfError::RasterWorkLimitExceeded); + } + } + + Ok(PaginationPlan { + points_per_css_pixel, + css_page_height, + page_count, + }) +} + +impl Page { + /// Export the current screen layout as a paginated raster PDF. + /// + /// The full document width is scaled uniformly into the printable width; + /// vertical slices become pages. This does not reflow into `@media print` + /// or implement CSS paged media, headers, footers, or page ranges. + pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result, RasterPdfError> { + let (page_width, page_height, printable_width, printable_height, left, bottom) = + options.page_geometry()?; + let js = self + .js + .as_ref() + .ok_or(RasterPdfError::NoRenderableDocument)?; + let (content_width, content_height) = js + .prepared_content_size() + .ok_or(RasterPdfError::NoRenderableDocument)?; + if !content_width.is_finite() + || !content_height.is_finite() + || content_width <= 0.0 + || content_height <= 0.0 + { + return Err(RasterPdfError::NoRenderableDocument); + } + + let plan = pagination_plan( + content_width, + content_height, + printable_width, + printable_height, + )?; + + encode_pdf_pages( + plan.page_count, + page_width, + page_height, + left, + bottom, + printable_height, + |page_index| { + let y = page_index as f32 * plan.css_page_height; + let slice_height = (content_height - y).min(plan.css_page_height); + let png = js + .screenshot_prepared_region(CaptureRegion::new( + 0.0, + y, + content_width, + slice_height, + 1.0, + )) + .map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?; + let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png) + .map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?; + // The document capture is already a complete PNG allocation. Drop + // it before converting the decoded pixels and, below, encoding the + // JPEG directly into the final PDF buffer. At no point do we retain + // PNGs or JPEGs for earlier pages. + drop(png); + let rgb = decoded.into_rgb8(); + Ok(RasterPage { + rgb, + draw_width_pt: printable_width, + draw_height_pt: slice_height * plan.points_per_css_pixel, + #[cfg(test)] + _lifetime_probe: None, + }) + }, + ) + } +} + +fn encode_pdf_pages( + page_count: usize, + page_width: f32, + page_height: f32, + left: f32, + bottom: f32, + printable_height: f32, + mut page_source: impl FnMut(usize) -> Result, +) -> Result, RasterPdfError> { + let object_count = 2usize + .checked_add( + page_count + .checked_mul(3) + .ok_or(RasterPdfError::OutputLimitExceeded)?, + ) + .ok_or(RasterPdfError::OutputLimitExceeded)?; + let mut writer = PdfWriter::new(object_count, MAX_PDF_OUTPUT_BYTES)?; + writer.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")?; + + let kids = (0..page_count) + .map(|index| format!("{} 0 R", 3 + index * 3)) + .collect::>() + .join(" "); + let pages_dictionary = format!("<< /Type /Pages /Count {page_count} /Kids [{kids}] >>"); + writer.write_object(2, pages_dictionary.as_bytes())?; + + for index in 0..page_count { + let page = page_source(index)?; + let page_id = 3 + index * 3; + let content_id = page_id + 1; + let image_id = page_id + 2; + let page_dictionary = format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_width:.3} {page_height:.3}] /Resources << /XObject << /Im0 {image_id} 0 R >> >> /Contents {content_id} 0 R >>" + ); + writer.write_object(page_id, page_dictionary.as_bytes())?; + + let draw_y = bottom + printable_height - page.draw_height_pt; + let commands = format!( + "q\n{:.3} 0 0 {:.3} {:.3} {:.3} cm\n/Im0 Do\nQ\n", + page.draw_width_pt, page.draw_height_pt, left, draw_y, + ); + let content = format!( + "<< /Length {} >>\nstream\n{}endstream", + commands.len(), + commands + ); + writer.write_object(content_id, content.as_bytes())?; + writer.write_rgb_image(image_id, &page.rgb)?; + // `page`, including its decoded RGB raster, is dropped here before + // the next page is captured. Only the bounded final PDF survives. + } + + writer.finish() +} + +struct PdfWriter { + output: Vec, + offsets: Vec, + limit: usize, + limit_exceeded: bool, +} + +impl PdfWriter { + fn new(object_count: usize, limit: usize) -> Result { + let mut writer = Self { + output: Vec::new(), + offsets: vec![0usize; object_count + 1], + limit, + limit_exceeded: false, + }; + writer.append(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")?; + Ok(writer) + } + + fn append(&mut self, bytes: &[u8]) -> Result<(), RasterPdfError> { + let new_len = self + .output + .len() + .checked_add(bytes.len()) + .ok_or(RasterPdfError::OutputLimitExceeded)?; + if new_len > self.limit { + self.limit_exceeded = true; + return Err(RasterPdfError::OutputLimitExceeded); + } + self.output.extend_from_slice(bytes); + Ok(()) + } + + fn write_object(&mut self, id: usize, body: &[u8]) -> Result<(), RasterPdfError> { + self.offsets[id] = self.output.len(); + self.append(format!("{id} 0 obj\n").as_bytes())?; + self.append(body)?; + self.append(b"\nendobj\n") + } + + fn write_rgb_image(&mut self, id: usize, rgb: &image::RgbImage) -> Result<(), RasterPdfError> { + self.offsets[id] = self.output.len(); + self.append(format!( + "{id} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ", + rgb.width(), rgb.height(), + ).as_bytes())?; + // Encode into the final PDF rather than building a second JPEG Vec. + // A fixed-width decimal token lets us patch /Length after encoding. + const LENGTH_DIGITS: usize = 20; + let length_offset = self.output.len(); + self.append(b"00000000000000000000 >>\nstream\n")?; + let stream_offset = self.output.len(); + let encode_result = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut *self, 90) + .write_image( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ); + if let Err(error) = encode_result { + return if self.limit_exceeded { + Err(RasterPdfError::OutputLimitExceeded) + } else { + Err(RasterPdfError::ImageEncode(error.to_string())) + }; + } + let stream_len = self.output.len() - stream_offset; + let length = format!("{stream_len:0LENGTH_DIGITS$}"); + if length.len() != LENGTH_DIGITS { + return Err(RasterPdfError::OutputLimitExceeded); + } + self.output[length_offset..length_offset + LENGTH_DIGITS] + .copy_from_slice(length.as_bytes()); + self.append(b"\nendstream\nendobj\n") + } + + fn finish(mut self) -> Result, RasterPdfError> { + let object_count = self.offsets.len() - 1; + let xref_offset = self.output.len(); + self.append(format!("xref\n0 {}\n0000000000 65535 f \n", object_count + 1).as_bytes())?; + for index in 1..self.offsets.len() { + let offset = self.offsets[index]; + self.append(format!("{offset:010} 00000 n \n").as_bytes())?; + } + self.append( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n", + object_count + 1, + ) + .as_bytes(), + )?; + Ok(self.output) + } +} + +impl io::Write for PdfWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.append(bytes) + .map(|()| bytes.len()) + .map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string())) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn options_reject_impossible_media_boxes() { + let mut options = RasterPdfOptions::default(); + options.paper_width_in = 0.0; + assert_eq!( + options.page_geometry(), + Err(RasterPdfError::InvalidPaperSize) + ); + let mut options = RasterPdfOptions::default(); + options.margin_left_in = 5.0; + options.margin_right_in = 5.0; + assert_eq!(options.page_geometry(), Err(RasterPdfError::InvalidMargins)); + } + + #[test] + fn pagination_preflight_bounds_pages_and_raster_work() { + let (_, _, printable_width, printable_height, _, _) = + RasterPdfOptions::default().page_geometry().unwrap(); + let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height) + .expect("an ordinary multi-page document stays inside the budget"); + assert!(ordinary.page_count > 1); + + assert_eq!( + pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(), + RasterPdfError::RasterWorkLimitExceeded, + "one excessively large raster page must fail before capture" + ); + assert_eq!( + pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(), + RasterPdfError::RasterWorkLimitExceeded, + "many individually valid pages must still respect a total work budget" + ); + assert_eq!( + pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(), + RasterPdfError::TooManyPages(MAX_PDF_PAGES), + ); + } + + #[test] + fn writer_emits_xref_and_one_image_per_page() { + let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| { + Ok(RasterPage { + rgb: image::RgbImage::from_pixel(2, 3, image::Rgb([10, 20, 30])), + draw_width_pt: 100.0, + draw_height_pt: 150.0, + _lifetime_probe: None, + }) + }) + .unwrap(); + assert!(pdf.starts_with(b"%PDF-1.4")); + assert!(pdf.ends_with(b"%%EOF\n")); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/Count 1")); + assert!(text.contains("/Subtype /Image")); + assert!(text.contains("xref\n0 6")); + let startxref = text + .rsplit_once("startxref\n") + .unwrap() + .1 + .lines() + .next() + .unwrap() + .parse::() + .unwrap(); + assert!(pdf[startxref..].starts_with(b"xref\n")); + let object_one_offset = text + .split("xref\n0 6\n") + .nth(1) + .unwrap() + .lines() + .nth(1) + .unwrap()[..10] + .parse::() + .unwrap(); + assert!(pdf[object_one_offset..].starts_with(b"1 0 obj\n")); + } + + #[test] + fn page_rasters_are_released_before_capturing_the_next_page() { + let previous = std::cell::RefCell::new(None::>); + let pdf = encode_pdf_pages(4, 612.0, 792.0, 36.0, 36.0, 720.0, |index| { + if let Some(previous) = previous.borrow().as_ref() { + assert!( + previous.upgrade().is_none(), + "page {index} was requested while the prior raster was still retained" + ); + } + let probe = std::rc::Rc::new(()); + *previous.borrow_mut() = Some(std::rc::Rc::downgrade(&probe)); + Ok(RasterPage { + rgb: image::RgbImage::from_pixel(8, 8, image::Rgb([index as u8, 0, 0])), + draw_width_pt: 100.0, + draw_height_pt: 100.0, + _lifetime_probe: Some(probe), + }) + }) + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&pdf) + .matches("/Subtype /Image") + .count(), + 4 + ); + } + + #[test] + fn writer_enforces_the_output_limit_while_encoding_the_image_stream() { + let mut writer = PdfWriter::new(5, 600).unwrap(); + writer + .write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>") + .unwrap(); + writer + .write_object(2, b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>") + .unwrap(); + let noisy = image::RgbImage::from_fn(128, 128, |x, y| { + image::Rgb([ + x.wrapping_mul(37) as u8, + y.wrapping_mul(53) as u8, + x.wrapping_add(y).wrapping_mul(71) as u8, + ]) + }); + assert_eq!( + writer.write_rgb_image(5, &noisy), + Err(RasterPdfError::OutputLimitExceeded) + ); + assert!(writer.output.len() <= 600); + } +} diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index baf37ae..3358ee1 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -11,7 +11,10 @@ use crate::types::{CdpEvent, CdpRequest, CdpResponse}; #[cfg(feature = "render")] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ScreencastFormat { Png, Jpeg } +pub(crate) enum ScreencastFormat { + Png, + Jpeg, +} #[cfg(feature = "render")] #[derive(Clone, Debug)] @@ -257,10 +260,15 @@ impl CdpContext { self.pages.retain(|p| p.id != id); #[cfg(feature = "render")] { - let removed: Vec = self.sessions.iter() + let removed: Vec = self + .sessions + .iter() .filter(|(_, page_id)| page_id.as_str() == id) - .map(|(session_id, _)| session_id.clone()).collect(); - for session_id in removed { self.screencasts.remove(&session_id); } + .map(|(session_id, _)| session_id.clone()) + .collect(); + for session_id in removed { + self.screencasts.remove(&session_id); + } } self.sessions.retain(|_, v| v != id); } @@ -273,9 +281,7 @@ impl CdpContext { } pub fn get_session_page(&self, session_id: &Option) -> Option<&Page> { - let page_id = session_id - .as_ref() - .and_then(|sid| self.sessions.get(sid))?; + let page_id = session_id.as_ref().and_then(|sid| self.sessions.get(sid))?; self.get_page(page_id) } @@ -311,38 +317,67 @@ impl CdpContext { /// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and /// must stay behind the lock. fn is_v8_free_method(method: &str) -> bool { - matches!(method, - "Target.getTargets" | "Target.setDiscoverTargets" - | "Target.attachToTarget" | "Target.attachToBrowserTarget" - | "Target.setAutoAttach" - | "Target.getBrowserContexts" | "Target.createBrowserContext" - | "Target.disposeBrowserContext" | "Target.getTargetInfo" - | "Target.detachFromTarget" | "Target.activateTarget" - | "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget" - | "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds" - | "Page.enable" | "Page.disable" | "Page.getFrameTree" - | "Page.setDownloadBehavior" - | "Page.setLifecycleEventsEnabled" - | "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument" - | "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory" - | "Page.resetNavigationHistory" | "Page.printToPDF" - | "Page.captureSnapshot" - | "Page.stopScreencast" | "Page.screencastFrameAck" - | "Page.createIsolatedWorld" - | "Runtime.enable" | "Runtime.disable" - | "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails" - | "Runtime.discardConsoleEntries" - | "Network.enable" | "Network.disable" | "Network.setCacheDisabled" - | "Network.setRequestInterception" | "Network.setBlockedURLs" - | "Network.setExtraHTTPHeaders" - | "Network.setUserAgentOverride" - | "Network.getCookies" | "Network.getAllCookies" - | "Network.setCookie" | "Network.setCookies" - | "Network.deleteCookies" | "Network.clearBrowserCookies" - | "Network.getResponseBody" - | "Fetch.continueRequest" | "Fetch.fulfillRequest" - | "Fetch.failRequest" | "Fetch.getResponseBody" - | "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies" + matches!( + method, + "Target.getTargets" + | "Target.setDiscoverTargets" + | "Target.attachToTarget" + | "Target.attachToBrowserTarget" + | "Target.setAutoAttach" + | "Target.getBrowserContexts" + | "Target.createBrowserContext" + | "Target.disposeBrowserContext" + | "Target.getTargetInfo" + | "Target.detachFromTarget" + | "Target.activateTarget" + | "Browser.getVersion" + | "Browser.close" + | "Browser.getWindowForTarget" + | "Browser.setDownloadBehavior" + | "Browser.getWindowBounds" + | "Browser.setWindowBounds" + | "Page.enable" + | "Page.disable" + | "Page.getFrameTree" + | "Page.setDownloadBehavior" + | "Page.setLifecycleEventsEnabled" + | "Page.addScriptToEvaluateOnNewDocument" + | "Page.removeScriptToEvaluateOnNewDocument" + | "Page.setInterceptFileChooserDialog" + | "Page.getNavigationHistory" + | "Page.resetNavigationHistory" + | "Page.captureSnapshot" + | "Page.stopScreencast" + | "Page.screencastFrameAck" + | "Page.createIsolatedWorld" + | "Runtime.enable" + | "Runtime.disable" + | "Runtime.runIfWaitingForDebugger" + | "Runtime.getExceptionDetails" + | "Runtime.discardConsoleEntries" + | "Network.enable" + | "Network.disable" + | "Network.setCacheDisabled" + | "Network.setRequestInterception" + | "Network.setBlockedURLs" + | "Network.setExtraHTTPHeaders" + | "Network.setUserAgentOverride" + | "Network.getCookies" + | "Network.getAllCookies" + | "Network.setCookie" + | "Network.setCookies" + | "Network.deleteCookies" + | "Network.clearBrowserCookies" + | "Network.getResponseBody" + | "Fetch.continueRequest" + | "Fetch.fulfillRequest" + | "Fetch.failRequest" + | "Fetch.getResponseBody" + | "IO.read" + | "IO.close" + | "Storage.getCookies" + | "Storage.setCookies" + | "Storage.deleteCookies" ) } @@ -401,7 +436,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { } else { ctx.get_session_page(&req.session_id) .and_then(|p| p.isolate_handle()) - .map(|h| obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms))) + .map(|h| { + obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms)) + }) }; let (domain, method) = match req.method.split_once('.') { @@ -421,7 +458,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { "Browser" => domains::browser::handle(method, &req.params).await, "Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await, "DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await, - "DOMSnapshot" => domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await, + "DOMSnapshot" => { + domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await + } "Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await, "Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await, "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await, @@ -430,16 +469,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { "Emulation" => domains::emulation::handle(method, &req.params, ctx, &req.session_id).await, "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await, "LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await, - "Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await, + "Accessibility" => { + domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await + } // Accepted but no-op. Puppeteer's FrameManager.initialize calls // Audits.enable on connect — refusing it breaks puppeteer.connect() // before any user code runs. - "Log" | "Performance" | "Security" | "CSS" - | "ServiceWorker" | "Inspector" - | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" - | "Audits" => { - Ok(json!({})) - } + "Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger" + | "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => Ok(json!({})), _ => Err(format!("Unknown domain: {}", domain)), }; @@ -599,7 +636,11 @@ mod tests { async fn audits_enable_returns_empty_success() { let mut ctx = CdpContext::new(); let resp = dispatch(&req("Audits.enable"), &mut ctx).await; - assert!(resp.error.is_none(), "Audits.enable should not error: {:?}", resp.error); + assert!( + resp.error.is_none(), + "Audits.enable should not error: {:?}", + resp.error + ); assert_eq!(resp.result, Some(json!({}))); } @@ -631,7 +672,11 @@ mod tests { }; let resp = dispatch(&outer, &mut ctx).await; - assert!(resp.error.is_none(), "wrapper must succeed: {:?}", resp.error); + assert!( + resp.error.is_none(), + "wrapper must succeed: {:?}", + resp.error + ); assert_eq!(resp.id, 99); assert_eq!(resp.result, Some(json!({}))); @@ -647,8 +692,14 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap(); assert_eq!(parsed["id"], 42); // Browser.getVersion returns a populated result object, not an error. - assert!(parsed.get("result").is_some(), "inner response carries result"); - assert!(parsed.get("error").is_none(), "inner response is not an error"); + assert!( + parsed.get("result").is_some(), + "inner response carries result" + ); + assert!( + parsed.get("error").is_none(), + "inner response is not an error" + ); } #[tokio::test] diff --git a/crates/obscura-cdp/src/domains/emulation.rs b/crates/obscura-cdp/src/domains/emulation.rs index 1e7799e..e6e07a0 100644 --- a/crates/obscura-cdp/src/domains/emulation.rs +++ b/crates/obscura-cdp/src/domains/emulation.rs @@ -2,6 +2,22 @@ use serde_json::{json, Value}; use crate::dispatch::CdpContext; +const MAX_DEVICE_METRIC_DIMENSION: i64 = 10_000_000; +const DEFAULT_VIEWPORT: (f32, f32) = (1280.0, 720.0); + +fn metric_dimension(params: &Value, name: &str) -> Result { + let value = params + .get(name) + .and_then(Value::as_i64) + .ok_or_else(|| format!("Emulation.setDeviceMetricsOverride requires integer {name}"))?; + if !(0..=MAX_DEVICE_METRIC_DIMENSION).contains(&value) { + return Err(format!( + "Emulation.setDeviceMetricsOverride {name} must be between 0 and {MAX_DEVICE_METRIC_DIMENSION}" + )); + } + Ok(value as u32) +} + pub async fn handle( method: &str, params: &Value, @@ -10,31 +26,42 @@ pub async fn handle( ) -> Result { match method { "setDeviceMetricsOverride" => { - let width = params - .get("width") + let width = metric_dimension(params, "width")?; + let height = metric_dimension(params, "height")?; + let device_scale_factor = params + .get("deviceScaleFactor") .and_then(Value::as_f64) - .ok_or("Emulation.setDeviceMetricsOverride requires width")?; - let height = params - .get("height") - .and_then(Value::as_f64) - .ok_or("Emulation.setDeviceMetricsOverride requires height")?; - if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + .ok_or("Emulation.setDeviceMetricsOverride requires deviceScaleFactor")?; + if !device_scale_factor.is_finite() || device_scale_factor < 0.0 { return Err( - "Emulation.setDeviceMetricsOverride requires positive finite dimensions" + "Emulation.setDeviceMetricsOverride requires a non-negative finite deviceScaleFactor" .to_string(), ); } let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; - page.set_viewport((width as f32, height as f32)); + page.set_viewport(( + if width == 0 { + DEFAULT_VIEWPORT.0 + } else { + width as f32 + }, + if height == 0 { + DEFAULT_VIEWPORT.1 + } else { + height as f32 + }, + )); + page.set_device_scale_factor(device_scale_factor as f32); Ok(json!({})) } "clearDeviceMetricsOverride" => { let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; - page.set_viewport((1280.0, 720.0)); + page.set_viewport(DEFAULT_VIEWPORT); + page.set_device_scale_factor(1.0); Ok(json!({})) } // Touch emulation does not affect layout yet, but acknowledging it is @@ -53,12 +80,11 @@ mod tests { let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session_id = Some("viewport-session".to_string()); - ctx.sessions - .insert(session_id.clone().unwrap(), page_id); + ctx.sessions.insert(session_id.clone().unwrap(), page_id); handle( "setDeviceMetricsOverride", - &json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}), + &json!({"width": 1024, "height": 768, "deviceScaleFactor": 2}), &mut ctx, &session_id, ) @@ -69,30 +95,103 @@ mod tests { .get_session_page_mut(&session_id) .expect("page for session"); assert_eq!(page.viewport, (1024.0, 768.0)); + assert_eq!(page.device_scale_factor, 2.0); assert_eq!( page.evaluate( "return [innerWidth, innerHeight, visualViewport.width,\ - visualViewport.height];" + visualViewport.height, devicePixelRatio];" ), - json!([1024, 768, 1024, 768]) + json!([1024, 768, 1024, 768, 2]) ); } #[tokio::test] - async fn device_metrics_override_rejects_zero_dimensions() { + async fn device_metrics_override_rejects_fractional_and_out_of_range_dimensions() { let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session_id = Some("viewport-session".to_string()); - ctx.sessions - .insert(session_id.clone().unwrap(), page_id); - let error = handle( + ctx.sessions.insert(session_id.clone().unwrap(), page_id); + for params in [ + json!({"width": 0.5, "height": 768, "deviceScaleFactor": 1}), + json!({"width": 10_000_001, "height": 768, "deviceScaleFactor": 1}), + json!({"width": -1, "height": 768, "deviceScaleFactor": 1}), + ] { + assert!( + handle("setDeviceMetricsOverride", ¶ms, &mut ctx, &session_id) + .await + .is_err(), + "must reject {params}" + ); + } + } + + #[tokio::test] + async fn zero_dimensions_disable_size_override() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = Some("zero-size-session".to_string()); + ctx.sessions.insert(session_id.clone().unwrap(), page_id); + handle( "setDeviceMetricsOverride", - &json!({"width": 0, "height": 768}), + &json!({"width": 0, "height": 0, "deviceScaleFactor": 0}), &mut ctx, &session_id, ) .await - .expect_err("zero width must fail"); - assert!(error.contains("positive finite dimensions")); + .expect("zero disables the overrides"); + + let page = ctx.get_session_page_mut(&session_id).unwrap(); + assert_eq!(page.viewport, DEFAULT_VIEWPORT); + assert_eq!(page.device_scale_factor, 1.0); + } + + #[tokio::test] + async fn device_scale_factor_zero_disables_override_and_clear_restores_defaults() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = Some("scale-session".to_string()); + ctx.sessions.insert(session_id.clone().unwrap(), page_id); + + handle( + "setDeviceMetricsOverride", + &json!({"width": 640, "height": 480, "deviceScaleFactor": 3}), + &mut ctx, + &session_id, + ) + .await + .unwrap(); + assert_eq!( + ctx.get_session_page(&session_id) + .unwrap() + .device_scale_factor, + 3.0 + ); + + handle( + "setDeviceMetricsOverride", + &json!({"width": 640, "height": 480, "deviceScaleFactor": 0}), + &mut ctx, + &session_id, + ) + .await + .expect("zero disables the scale override"); + assert_eq!( + ctx.get_session_page(&session_id) + .unwrap() + .device_scale_factor, + 1.0 + ); + + handle( + "clearDeviceMetricsOverride", + &json!({}), + &mut ctx, + &session_id, + ) + .await + .unwrap(); + let page = ctx.get_session_page(&session_id).unwrap(); + assert_eq!(page.viewport, (1280.0, 720.0)); + assert_eq!(page.device_scale_factor, 1.0); } } diff --git a/crates/obscura-cdp/src/domains/fetch.rs b/crates/obscura-cdp/src/domains/fetch.rs index 1431640..2c5e39d 100644 --- a/crates/obscura-cdp/src/domains/fetch.rs +++ b/crates/obscura-cdp/src/domains/fetch.rs @@ -115,10 +115,19 @@ pub async fn handle( if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) { let _ = paused.resolver.send(FetchResolution::Continue { - url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()), - method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()), + url: params + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + method: params + .get("method") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), headers: None, - post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()), + post_data: params + .get("postData") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), }); } Ok(json!({})) @@ -178,9 +187,7 @@ pub async fn handle( } Ok(json!({})) } - "getResponseBody" => { - Ok(json!({ "body": "", "base64Encoded": false })) - } + "getResponseBody" => Ok(json!({ "body": "", "base64Encoded": false })), "takeResponseBodyAsStream" => { // Hand the client a streaming handle for a large response body so it // can pull it in chunks via IO.read and free it with IO.close, @@ -206,7 +213,10 @@ pub async fn handle( format!("Fetch.takeResponseBodyAsStream: no cached body for {request_id}") })?; - let handle = ctx.io_streams.insert(bytes); + let handle = ctx + .io_streams + .insert(bytes) + .map_err(|error| format!("Fetch.takeResponseBodyAsStream: {error}"))?; Ok(json!({ "stream": handle })) } _ => Err(format!("Unknown Fetch method: {}", method)), diff --git a/crates/obscura-cdp/src/domains/io.rs b/crates/obscura-cdp/src/domains/io.rs index ad1c2c4..dff4cf5 100644 --- a/crates/obscura-cdp/src/domains/io.rs +++ b/crates/obscura-cdp/src/domains/io.rs @@ -9,6 +9,7 @@ use crate::dispatch::CdpContext; // order of magnitude; keeping chunks bounded is the point of streaming (issue // #360), so we never return the whole body in one IO.read. const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB +const MAX_READ_CHUNK: usize = 4 << 20; // 4 MiB fn io_stream_max_entries() -> usize { std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES") @@ -35,48 +36,90 @@ fn io_stream_max_bytes() -> usize { /// memory stays bounded regardless of client behavior. Reading an evicted /// handle fails cleanly (the client re-takes or gives up), which is the right /// trade against an OOM. -#[derive(Default)] pub struct IoStreamStore { streams: HashMap, usize)>, order: VecDeque, total_bytes: usize, counter: u64, + max_entries: usize, + max_bytes: usize, +} + +impl Default for IoStreamStore { + fn default() -> Self { + Self::with_limits(io_stream_max_entries(), io_stream_max_bytes()) + } } impl IoStreamStore { - /// Store a body and return its handle, evicting the oldest streams if this - /// pushes the store past its entry or byte cap. - pub fn insert(&mut self, bytes: Vec) -> String { - let handle = format!("stream-{}", self.counter); - self.counter += 1; - self.total_bytes += bytes.len(); - self.streams.insert(handle.clone(), (bytes, 0)); - self.order.push_back(handle.clone()); + fn with_limits(max_entries: usize, max_bytes: usize) -> Self { + Self { + streams: HashMap::new(), + order: VecDeque::new(), + total_bytes: 0, + counter: 0, + max_entries: max_entries.max(1), + max_bytes, + } + } - let max_entries = io_stream_max_entries().max(1); - let max_bytes = io_stream_max_bytes(); - // Evict oldest streams while over either cap. Never evict the stream we - // just inserted (it sits at the back of `order`); a lone oversized body - // is kept, since the client explicitly asked to stream it. - while self.order.len() > 1 - && (self.order.len() > max_entries || self.total_bytes > max_bytes) + /// Store a body and return its handle, evicting the oldest streams if this + /// would push the store past its entry or byte cap. A single body larger + /// than the byte cap is rejected rather than becoming an unbounded + /// exception to the store's memory contract. + pub fn insert(&mut self, bytes: Vec) -> Result { + if bytes.len() > self.max_bytes { + return Err(format!( + "IO stream body is {} bytes, exceeding the {}-byte per-context limit", + bytes.len(), + self.max_bytes, + )); + } + + while !self.order.is_empty() + && (self.order.len() >= self.max_entries + || self + .total_bytes + .checked_add(bytes.len()) + .is_none_or(|total| total > self.max_bytes)) { if let Some(oldest) = self.order.pop_front() { - if let Some((b, _)) = self.streams.remove(&oldest) { - self.total_bytes -= b.len(); + if let Some((body, _)) = self.streams.remove(&oldest) { + self.total_bytes = self.total_bytes.saturating_sub(body.len()); } } } - handle + + let handle = format!("stream-{}", self.counter); + self.counter = self + .counter + .checked_add(1) + .ok_or("IO stream handle space exhausted")?; + self.total_bytes = self + .total_bytes + .checked_add(bytes.len()) + .ok_or("IO stream byte accounting overflow")?; + self.streams.insert(handle.clone(), (bytes, 0)); + self.order.push_back(handle.clone()); + Ok(handle) } /// Read up to `size` bytes from the stream, advancing its cursor. Returns /// the base64 chunk and whether EOF was reached, or None for an unknown or /// already-freed handle. - pub fn read(&mut self, handle: &str, size: usize) -> Option<(String, bool)> { + pub fn read( + &mut self, + handle: &str, + offset: Option, + size: usize, + ) -> Option<(String, bool)> { let (bytes, cursor) = self.streams.get_mut(handle)?; + if let Some(offset) = offset { + *cursor = offset.min(bytes.len()); + } + let size = size.min(MAX_READ_CHUNK); let start = (*cursor).min(bytes.len()); - let end = start.saturating_add(size.max(1)).min(bytes.len()); + let end = start.saturating_add(size).min(bytes.len()); let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]); *cursor = end; Some((data, end >= bytes.len())) @@ -103,13 +146,29 @@ pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Resul .ok_or("IO.read requires handle")?; let size = params .get("size") - .and_then(|v| v.as_u64()) - .map(|s| s as usize) + .map(|value| { + value + .as_i64() + .filter(|size| *size >= 0) + .and_then(|size| usize::try_from(size).ok()) + .ok_or("IO.read size must be a non-negative integer") + }) + .transpose()? .unwrap_or(DEFAULT_CHUNK); + let offset = params + .get("offset") + .map(|value| { + value + .as_i64() + .filter(|offset| *offset >= 0) + .and_then(|offset| usize::try_from(offset).ok()) + .ok_or("IO.read offset must be a non-negative integer") + }) + .transpose()?; let (data, eof) = ctx .io_streams - .read(handle, size) + .read(handle, offset, size) .ok_or_else(|| format!("IO.read: unknown handle {handle}"))?; Ok(json!({ "data": data, "eof": eof, "base64Encoded": true })) @@ -136,48 +195,89 @@ mod tests { #[test] fn reads_chunks_then_frees() { - let mut store = IoStreamStore::default(); - let h = store.insert(b"hello".to_vec()); + let mut store = IoStreamStore::with_limits(4, 1024); + let h = store.insert(b"hello".to_vec()).unwrap(); - let (d1, eof1) = store.read(&h, 3).unwrap(); + let (d1, eof1) = store.read(&h, None, 3).unwrap(); assert_eq!(decode(&d1), b"hel"); assert!(!eof1); - let (d2, eof2) = store.read(&h, 3).unwrap(); + let (d2, eof2) = store.read(&h, None, 3).unwrap(); assert_eq!(decode(&d2), b"lo"); assert!(eof2); store.remove(&h); - assert!(store.read(&h, 3).is_none()); + assert!(store.read(&h, None, 3).is_none()); + } + + #[test] + fn read_offset_seeks_and_zero_size_does_not_advance() { + let mut store = IoStreamStore::with_limits(2, 1024); + let handle = store.insert(b"abcdef".to_vec()).unwrap(); + + let (empty, eof) = store.read(&handle, Some(1), 0).unwrap(); + assert_eq!(decode(&empty), b""); + assert!(!eof); + let (middle, eof) = store.read(&handle, None, 2).unwrap(); + assert_eq!(decode(&middle), b"bc"); + assert!(!eof); + let (tail, eof) = store.read(&handle, Some(4), 10).unwrap(); + assert_eq!(decode(&tail), b"ef"); + assert!(eof); + } + + #[tokio::test] + async fn read_rejects_negative_or_non_integer_ranges() { + let mut ctx = CdpContext::new(); + let handle_id = ctx.io_streams.insert(b"data".to_vec()).unwrap(); + for params in [ + json!({"handle": handle_id.clone(), "offset": -1}), + json!({"handle": handle_id.clone(), "size": -1}), + json!({"handle": handle_id, "offset": 1.5}), + ] { + assert!(handle("read", ¶ms, &mut ctx).await.is_err(), "{params}"); + } } #[test] fn evicts_oldest_over_entry_cap() { - std::env::set_var("OBSCURA_IO_STREAM_MAX_ENTRIES", "3"); - let mut store = IoStreamStore::default(); - let h0 = store.insert(vec![0]); - let h1 = store.insert(vec![1]); - let _h2 = store.insert(vec![2]); - let h3 = store.insert(vec![3]); // 4th entry, cap 3 -> h0 evicted - std::env::remove_var("OBSCURA_IO_STREAM_MAX_ENTRIES"); + let mut store = IoStreamStore::with_limits(3, 1024); + let h0 = store.insert(vec![0]).unwrap(); + let h1 = store.insert(vec![1]).unwrap(); + let _h2 = store.insert(vec![2]).unwrap(); + let h3 = store.insert(vec![3]).unwrap(); // 4th entry, cap 3 -> h0 evicted - assert!(store.read(&h0, 10).is_none(), "oldest stream should be evicted"); - assert!(store.read(&h1, 10).is_some()); - assert!(store.read(&h3, 10).is_some()); + assert!( + store.read(&h0, None, 10).is_none(), + "oldest stream should be evicted" + ); + assert!(store.read(&h1, None, 10).is_some()); + assert!(store.read(&h3, None, 10).is_some()); } #[test] - fn evicts_over_byte_cap_but_keeps_newest() { - std::env::set_var("OBSCURA_IO_STREAM_MAX_BYTES", "10"); - let mut store = IoStreamStore::default(); - let h0 = store.insert(vec![0u8; 8]); - let h1 = store.insert(vec![1u8; 8]); // 16 > 10 -> h0 evicted - // A single body larger than the cap is still kept (client asked for it). - let big = store.insert(vec![2u8; 100]); - std::env::remove_var("OBSCURA_IO_STREAM_MAX_BYTES"); + fn evicts_over_byte_cap_and_rejects_oversized_body() { + let mut store = IoStreamStore::with_limits(4, 10); + let h0 = store.insert(vec![0u8; 8]).unwrap(); + let h1 = store.insert(vec![1u8; 8]).unwrap(); // 16 > 10 -> h0 evicted + let error = store + .insert(vec![2u8; 100]) + .expect_err("a single body cannot bypass the context byte cap"); - assert!(store.read(&h0, 100).is_none()); - assert!(store.read(&big, 200).is_some(), "the just-inserted body is never evicted"); - let _ = h1; + assert!(store.read(&h0, None, 100).is_none()); + assert!(store.read(&h1, None, 100).is_some()); + assert!(error.contains("exceeding"), "{error}"); + } + + #[test] + fn requested_read_size_is_capped() { + let mut store = IoStreamStore::with_limits(2, MAX_READ_CHUNK * 2); + let handle = store.insert(vec![7u8; MAX_READ_CHUNK + 17]).unwrap(); + let (first, eof) = store.read(&handle, None, usize::MAX).unwrap(); + assert_eq!(decode(&first).len(), MAX_READ_CHUNK); + assert!(!eof); + let (second, eof) = store.read(&handle, None, usize::MAX).unwrap(); + assert_eq!(decode(&second).len(), 17); + assert!(eof); } } diff --git a/crates/obscura-cdp/src/domains/mod.rs b/crates/obscura-cdp/src/domains/mod.rs index 7af96ae..3ad2867 100644 --- a/crates/obscura-cdp/src/domains/mod.rs +++ b/crates/obscura-cdp/src/domains/mod.rs @@ -1,14 +1,15 @@ -pub mod target; +pub mod accessibility; pub mod browser; -pub mod page; pub mod dom; pub mod domsnapshot; -pub mod runtime; -pub mod network; -pub mod fetch; -pub mod io; -pub mod input; -pub mod storage; -pub mod accessibility; -pub mod lp; pub mod emulation; +pub mod fetch; +pub mod input; +pub mod io; +pub mod lp; +pub mod network; +pub mod page; +pub mod pdf; +pub mod runtime; +pub mod storage; +pub mod target; diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 89ae8a5..70fe096 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -143,73 +143,61 @@ fn parse_screenshot_options(params: &Value) -> Result } #[cfg(feature = "render")] -fn screenshot_output_size(clip: ScreenshotClip) -> Result<(u32, u32), String> { - // Chromium converts the CSS clip size to an integer gfx::Size before - // applying scale to the requested output bitmap. - let width = (clip.width.trunc() * clip.scale).round(); - let height = (clip.height.trunc() * clip.scale).round(); - const MAX_DIMENSION: f64 = 128.0 * 1024.0; - if width <= 0.0 || height <= 0.0 { - return Err("Screenshot clip is too small at the requested scale.".to_string()); +fn capture_error_message(error: obscura_browser::CaptureError) -> String { + match error { + obscura_browser::CaptureError::InvalidRegion => { + "Page.captureScreenshot received an invalid capture region".to_string() + } + obscura_browser::CaptureError::AllocationLimitExceeded => { + "Page.captureScreenshot bitmap is too large".to_string() + } + obscura_browser::CaptureError::PaintFailed => { + "Page.captureScreenshot failed: the page has no retained DOM surface to render" + .to_string() + } + obscura_browser::CaptureError::EncodeFailed => { + "Page.captureScreenshot renderer PNG encoding failed".to_string() + } } - if width >= MAX_DIMENSION || height >= MAX_DIMENSION { - return Err("Page is too large.".to_string()); - } - let pixels = width * height; - // Four RGBA bytes per pixel keeps this guard below a 256 MiB surface. - if !pixels.is_finite() || pixels > 64.0 * 1024.0 * 1024.0 { - return Err("Screenshot bitmap is too large.".to_string()); - } - Ok((width as u32, height as u32)) } #[cfg(feature = "render")] -fn sample_screenshot_region( - source: &image::RgbaImage, +fn chromium_clip_region( clip: ScreenshotClip, - relative_x: f64, - relative_y: f64, -) -> Result { - let (output_width, output_height) = screenshot_output_size(clip)?; - let mut output = image::RgbaImage::new(output_width, output_height); - let source_width = source.width() as i64; - let source_height = source.height() as i64; - - // Sampling pixel centers gives exact copies for integral, scale=1 clips - // while also handling fractional clip origins and non-integral scales. - for output_y in 0..output_height { - let sample_y = - relative_y + (f64::from(output_y) + 0.5) * clip.height / f64::from(output_height) - 0.5; - let y0 = sample_y.floor() as i64; - let y1 = y0 + 1; - let fy = sample_y - y0 as f64; - for output_x in 0..output_width { - let sample_x = relative_x - + (f64::from(output_x) + 0.5) * clip.width / f64::from(output_width) - - 0.5; - let x0 = sample_x.floor() as i64; - let x1 = x0 + 1; - let fx = sample_x - x0 as f64; - let pixel = |x: i64, y: i64| { - source.get_pixel( - x.clamp(0, source_width - 1) as u32, - y.clamp(0, source_height - 1) as u32, - ) - }; - let p00 = pixel(x0, y0); - let p10 = pixel(x1, y0); - let p01 = pixel(x0, y1); - let p11 = pixel(x1, y1); - let mut rgba = [0u8; 4]; - for channel in 0..4 { - let top = f64::from(p00[channel]) * (1.0 - fx) + f64::from(p10[channel]) * fx; - let bottom = f64::from(p01[channel]) * (1.0 - fx) + f64::from(p11[channel]) * fx; - rgba[channel] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8; - } - output.put_pixel(output_x, output_y, image::Rgba(rgba)); - } + device_scale_factor: f64, +) -> Result { + // Chromium first converts the CSS clip size to an integer gfx::Size, then + // applies the effective clip/device scale and rounds to output pixels. + // Keeping this calculation here avoids asking the raster layer to infer a + // protocol-specific size from the original fractional rectangle. + let width = clip.width.trunc(); + let height = clip.height.trunc(); + if width <= 0.0 || height <= 0.0 { + return Err("Screenshot clip is too small at the requested scale.".to_string()); } - Ok(output) + let effective_scale = clip.scale * device_scale_factor; + let output_width = (width * effective_scale).round(); + let output_height = (height * effective_scale).round(); + if !effective_scale.is_finite() + || effective_scale <= 0.0 + || !output_width.is_finite() + || !output_height.is_finite() + || output_width <= 0.0 + || output_height <= 0.0 + || output_width > f64::from(u32::MAX) + || output_height > f64::from(u32::MAX) + { + return Err("Page.captureScreenshot bitmap is too large".to_string()); + } + Ok(obscura_browser::CaptureRegion::with_output_size( + clip.x as f32, + clip.y as f32, + width as f32, + height as f32, + effective_scale as f32, + output_width as u32, + output_height as u32, + )) } #[cfg(feature = "render")] @@ -282,42 +270,64 @@ fn encode_screenshot( fn screencast_int32(params: &Value, name: &str) -> Result, String> { match params.get(name) { None => Ok(None), - Some(value) => value.as_i64() - .filter(|value| i32::try_from(*value).is_ok()).map(Some) + Some(value) => value + .as_i64() + .filter(|value| i32::try_from(*value).is_ok()) + .map(Some) .ok_or_else(|| format!("Invalid parameters: {name} must be an integer")), } } #[cfg(feature = "render")] fn parse_screencast_state(params: &Value, session_id: i64) -> Result { - if !params.is_object() { return Err("Invalid parameters: expected an object".into()); } + if !params.is_object() { + return Err("Invalid parameters: expected an object".into()); + } let format = match params.get("format") { None => ScreencastFormat::Png, Some(Value::String(value)) if value == "png" => ScreencastFormat::Png, Some(Value::String(value)) if value == "jpeg" => ScreencastFormat::Jpeg, - Some(Value::String(_)) => return Err("Invalid parameters: screencast format must be png or jpeg".into()), + Some(Value::String(_)) => { + return Err("Invalid parameters: screencast format must be png or jpeg".into()) + } Some(_) => return Err("Invalid parameters: format must be a string".into()), }; let quality = screencast_int32(params, "quality")?.unwrap_or(DEFAULT_SCREENSHOT_QUALITY); - let quality = if (0..=100).contains(&quality) { quality } else { DEFAULT_SCREENSHOT_QUALITY } as u8; + let quality = if (0..=100).contains(&quality) { + quality + } else { + DEFAULT_SCREENSHOT_QUALITY + } as u8; let dimension = |name: &str| -> Result, String> { - Ok(screencast_int32(params, name)?.filter(|value| *value > 0).map(|value| value as u32)) + Ok(screencast_int32(params, name)? + .filter(|value| *value > 0) + .map(|value| value as u32)) }; let every_nth_frame = screencast_int32(params, "everyNthFrame")?.unwrap_or(1); if every_nth_frame <= 0 { return Err("Invalid parameters: everyNthFrame must be greater than zero".into()); } Ok(ScreencastState { - format, quality, - max_width: dimension("maxWidth")?, max_height: dimension("maxHeight")?, - every_nth_frame: every_nth_frame as u32, command_frame_counter: 0, - session_id, frames_in_flight: 0, + format, + quality, + max_width: dimension("maxWidth")?, + max_height: dimension("maxHeight")?, + every_nth_frame: every_nth_frame as u32, + command_frame_counter: 0, + session_id, + frames_in_flight: 0, }) } #[cfg(feature = "render")] -fn encode_screencast_frame(renderer_png: Vec, state: &ScreencastState) -> Result, String> { - if state.format == ScreencastFormat::Png && state.max_width.is_none() && state.max_height.is_none() { +fn encode_screencast_frame( + renderer_png: Vec, + state: &ScreencastState, +) -> Result, String> { + if state.format == ScreencastFormat::Png + && state.max_width.is_none() + && state.max_height.is_none() + { return Ok(renderer_png); } let source = image::load_from_memory_with_format(&renderer_png, image::ImageFormat::Png) @@ -334,54 +344,98 @@ fn encode_screencast_frame(renderer_png: Vec, state: &ScreencastState) -> Re (f64::from(source.width()) * scale).floor().max(1.0) as u32, (f64::from(source.height()) * scale).floor().max(1.0) as u32, ); - let raster = if size == source.dimensions() { source } else { - image::imageops::resize(&source, size.0, size.1, image::imageops::FilterType::Triangle) + let raster = if size == source.dimensions() { + source + } else { + image::imageops::resize( + &source, + size.0, + size.1, + image::imageops::FilterType::Triangle, + ) }; let format = match state.format { ScreencastFormat::Png => ScreenshotFormat::Png, ScreencastFormat::Jpeg => ScreenshotFormat::Jpeg, }; - encode_screenshot(&raster, ScreenshotOptions { - format, quality: state.quality, quality_supplied: true, clip: None, - from_surface: true, capture_beyond_viewport: false, optimize_for_speed: false, - }) + encode_screenshot( + &raster, + ScreenshotOptions { + format, + quality: state.quality, + quality_supplied: true, + clip: None, + from_surface: true, + capture_beyond_viewport: false, + optimize_for_speed: false, + }, + ) } /// Queue a visible-viewport frame through normal CDP event transport. This is /// intentionally command-driven until Obscura has a compositor frame pump. #[cfg(feature = "render")] pub(crate) fn queue_screencast_frame( - ctx: &mut CdpContext, cdp_session_id: &Option, force: bool, + ctx: &mut CdpContext, + cdp_session_id: &Option, + force: bool, ) -> Result { - let cdp_session_id = cdp_session_id.as_deref() + let cdp_session_id = cdp_session_id + .as_deref() .ok_or("Page.startScreencast requires an attached target session")?; let state = { - let Some(state) = ctx.screencasts.get_mut(cdp_session_id) else { return Ok(false); }; - if state.frames_in_flight >= MAX_SCREENCAST_FRAMES_IN_FLIGHT { return Ok(false); } + let Some(state) = ctx.screencasts.get_mut(cdp_session_id) else { + return Ok(false); + }; + if state.frames_in_flight >= MAX_SCREENCAST_FRAMES_IN_FLIGHT { + return Ok(false); + } if !force { state.command_frame_counter = state.command_frame_counter.saturating_add(1); - if state.command_frame_counter % u64::from(state.every_nth_frame) != 0 { return Ok(false); } + if state.command_frame_counter % u64::from(state.every_nth_frame) != 0 { + return Ok(false); + } } state.clone() }; let attached_session = Some(cdp_session_id.to_string()); let (viewport, scroll, png) = { - let page = ctx.get_session_page_mut(&attached_session).ok_or("No page for session")?; + let page = ctx + .get_session_page_mut(&attached_session) + .ok_or("No page for session")?; let viewport = page.viewport; - let scroll = page.evaluate("[window.scrollX, window.scrollY]").as_array() - .map(|values| ( - values.first().and_then(Value::as_f64).unwrap_or(0.0), - values.get(1).and_then(Value::as_f64).unwrap_or(0.0), - )).unwrap_or((0.0, 0.0)); - let png = page.screenshot(viewport).ok_or_else(|| - "Page.startScreencast failed: the page has no visible DOM surface to render".to_string())?; + let scroll = page + .evaluate("[window.scrollX, window.scrollY]") + .as_array() + .map(|values| { + ( + values.first().and_then(Value::as_f64).unwrap_or(0.0), + values.get(1).and_then(Value::as_f64).unwrap_or(0.0), + ) + }) + .unwrap_or((0.0, 0.0)); + obscura_browser::validate_capture_region(obscura_browser::CaptureRegion::new( + scroll.0 as f32, + scroll.1 as f32, + viewport.0, + viewport.1, + 1.0, + )) + .map_err(capture_error_message)?; + let png = page.screenshot(viewport).ok_or_else(|| { + "Page.startScreencast failed: the page has no visible DOM surface to render".to_string() + })?; (viewport, scroll, png) }; let encoded = encode_screencast_frame(png, &state)?; use base64::Engine as _; let data = base64::engine::general_purpose::STANDARD.encode(encoded); - let Some(live) = ctx.screencasts.get_mut(cdp_session_id) else { return Ok(false); }; - if live.session_id != state.session_id { return Ok(false); } + let Some(live) = ctx.screencasts.get_mut(cdp_session_id) else { + return Ok(false); + }; + if live.session_id != state.session_id { + return Ok(false); + } live.frames_in_flight = live.frames_in_flight.saturating_add(1); ctx.pending_events.push(CdpEvent { method: "Page.screencastFrame".into(), @@ -402,12 +456,22 @@ pub(crate) fn queue_screencast_frame( #[cfg(feature = "render")] pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool { - matches!(method, - "Page.navigate" | "Page.reload" | "Page.navigateToHistoryEntry" - | "Runtime.evaluate" | "Runtime.callFunctionOn" - | "Input.dispatchMouseEvent" | "Input.dispatchKeyEvent" | "Input.dispatchTouchEvent" - | "Emulation.setDeviceMetricsOverride" | "Emulation.clearDeviceMetricsOverride" - | "DOM.setAttributeValue" | "DOM.removeNode" | "DOM.focus" | "DOM.setFileInputFiles" + matches!( + method, + "Page.navigate" + | "Page.reload" + | "Page.navigateToHistoryEntry" + | "Runtime.evaluate" + | "Runtime.callFunctionOn" + | "Input.dispatchMouseEvent" + | "Input.dispatchKeyEvent" + | "Input.dispatchTouchEvent" + | "Emulation.setDeviceMetricsOverride" + | "Emulation.clearDeviceMetricsOverride" + | "DOM.setAttributeValue" + | "DOM.removeNode" + | "DOM.focus" + | "DOM.setFileInputFiles" ) } @@ -434,14 +498,17 @@ pub fn emit_navigation_events( // via `requestId === loaderId && type === "Document"` (issue #189). let nav_request_ids: Vec = { let mut nav_seen = false; - network_events.iter().map(|ev| { - if !nav_seen && ev.resource_type == "Document" && ev.url == page_url { - nav_seen = true; - loader_id.to_string() - } else { - ev.request_id.clone() - } - }).collect() + network_events + .iter() + .map(|ev| { + if !nav_seen && ev.resource_type == "Document" && ev.url == page_url { + nav_seen = true; + loader_id.to_string() + } else { + ev.request_id.clone() + } + }) + .collect() }; let nav_idx: Option = network_events .iter() @@ -487,10 +554,26 @@ pub fn emit_navigation_events( // set was insert-only, so stale ids kept validating and grew unbounded. ctx.valid_context_ids.clear(); let mut phase1 = vec![ - CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, - CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, - CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, - CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, + CdpEvent { + method: "Page.lifecycleEvent".into(), + params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), + session_id: es.clone(), + }, + CdpEvent { + method: "Runtime.executionContextsCleared".into(), + params: json!({}), + session_id: es.clone(), + }, + CdpEvent { + method: "Page.frameNavigated".into(), + params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), + session_id: es.clone(), + }, + CdpEvent { + method: "Runtime.executionContextCreated".into(), + params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), + session_id: es.clone(), + }, ]; // The default world is re-created as context id 2; re-register it. Isolated // worlds register themselves via next_isolated_context in the loop below. @@ -555,16 +638,36 @@ pub fn emit_navigation_events( } let mut phase3 = vec![ - CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() }, - CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, - CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() }, - CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, + CdpEvent { + method: "Page.lifecycleEvent".into(), + params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), + session_id: es.clone(), + }, + CdpEvent { + method: "Page.domContentEventFired".into(), + params: json!({"timestamp": ts}), + session_id: es.clone(), + }, + CdpEvent { + method: "Page.lifecycleEvent".into(), + params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), + session_id: es.clone(), + }, + CdpEvent { + method: "Page.loadEventFired".into(), + params: json!({"timestamp": ts}), + session_id: es.clone(), + }, ]; if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) { let idle_ts = timestamp(); phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() }); } - phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es }); + phase3.push(CdpEvent { + method: "Page.frameStoppedLoading".into(), + params: json!({"frameId": frame_id}), + session_id: es, + }); ctx.pending_events.extend(phase3); // Target.targetInfoChanged: strict CDP clients (browser-use, and @@ -655,7 +758,9 @@ async fn do_navigate( let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect(); let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = { - let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; let frame_id = page.frame_id.clone(); let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); @@ -664,12 +769,19 @@ async fn do_navigate( // the page so navigate_single can inject them at the right point. page.set_preload_scripts(preload_scripts); - let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET"); + let nav_method = params + .get("__method") + .and_then(|v| v.as_str()) + .unwrap_or("GET"); let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or(""); if nav_method == "POST" && !nav_body.is_empty() { - page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?; + page.navigate_with_wait_post(url, wait_until, nav_method, nav_body) + .await + .map_err(|e| e.to_string())?; } else { - page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?; + page.navigate_with_wait(url, wait_until) + .await + .map_err(|e| e.to_string())?; } let reached_network_idle = page.lifecycle.is_network_idle(); @@ -679,7 +791,14 @@ async fn do_navigate( let network_events: Vec<_> = page.network_events.drain(..).collect(); let page_url = page.url_string(); let page_id = page.id.clone(); - (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) + ( + frame_id, + loader_id, + network_events, + page_url, + page_id, + reached_network_idle, + ) }; emit_navigation_events( @@ -709,12 +828,15 @@ pub async fn handle( match method { "enable" => Ok(json!({})), "navigate" => { - let url = params.get("url").and_then(|v| v.as_str()) + let url = params + .get("url") + .and_then(|v| v.as_str()) .ok_or("url required")?; do_navigate(url, params, ctx, session_id).await } "reload" => { - let current_url = ctx.get_session_page(session_id) + let current_url = ctx + .get_session_page(session_id) .map(|p| p.url_string()) .unwrap_or_else(|| "about:blank".to_string()); let reload_params = json!({ @@ -723,7 +845,9 @@ pub async fn handle( do_navigate(¤t_url, &reload_params, ctx, session_id).await } "getFrameTree" => { - let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page(session_id) + .ok_or("No page for session")?; Ok(json!({ "frameTree": { "frame": { @@ -741,12 +865,20 @@ pub async fn handle( } "createIsolatedWorld" => { let (frame_id_param, world_name, page_url, page_id) = { - let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page(session_id) + .ok_or("No page for session")?; ( - params.get("frameId").and_then(|v| v.as_str()) - .unwrap_or(&page.frame_id).to_string(), - params.get("worldName").and_then(|v| v.as_str()) - .unwrap_or("").to_string(), + params + .get("frameId") + .and_then(|v| v.as_str()) + .unwrap_or(&page.frame_id) + .to_string(), + params + .get("worldName") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), page.url_string(), page.id.clone(), ) @@ -792,12 +924,16 @@ pub async fn handle( ctx.preload_counter += 1; let identifier = format!("{}", ctx.preload_counter); if !source.is_empty() { - ctx.preload_scripts.push((identifier.clone(), source.to_string())); + ctx.preload_scripts + .push((identifier.clone(), source.to_string())); } Ok(json!({ "identifier": identifier })) } "removeScriptToEvaluateOnNewDocument" => { - let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or(""); + let identifier = params + .get("identifier") + .and_then(|v| v.as_str()) + .unwrap_or(""); ctx.preload_scripts.retain(|(id, _)| id != identifier); Ok(json!({})) } @@ -864,7 +1000,9 @@ pub async fn handle( })) } "getNavigationHistory" => { - let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page(session_id) + .ok_or("No page for session")?; // Synthesize an entry for the current page when history is empty // (initial about:blank, never-navigated targets). Puppeteer's // goBack reads `currentIndex` and `entries[currentIndex-1]`; @@ -894,7 +1032,9 @@ pub async fn handle( "navigateToHistoryEntry" => { let entry_id = params.get("entryId").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let target_url = { - let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; let url = page.history.get(entry_id).cloned(); if url.is_some() { page.set_history_index(entry_id); @@ -905,12 +1045,18 @@ pub async fn handle( // Stash + restore history so push_history doesn't clobber // the cursor we just moved. let stash = { - let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; (page.history.clone(), page.history_index) }; let (frame_id, page_id, network_events, page_url, reached_idle) = { - let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; - page.navigate_with_wait(&url, WaitUntil::DomContentLoaded).await.map_err(|e| e.to_string())?; + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + page.navigate_with_wait(&url, WaitUntil::DomContentLoaded) + .await + .map_err(|e| e.to_string())?; page.history = stash.0; page.history_index = stash.1; ( @@ -923,9 +1069,15 @@ pub async fn handle( }; let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); emit_navigation_events( - ctx, session_id, - &frame_id, &loader_id, &page_url, &page_id, - &network_events, WaitUntil::DomContentLoaded, reached_idle, + ctx, + session_id, + &frame_id, + &loader_id, + &page_url, + &page_id, + &network_events, + WaitUntil::DomContentLoaded, + reached_idle, ); } Ok(json!({})) @@ -937,46 +1089,47 @@ pub async fn handle( } Ok(json!({})) } - "printToPDF" => { - // Obscura has no layout/rendering engine, so PDF generation is - // intentionally not implemented. Returning a distinct, descriptive - // error (rather than the generic "Unknown Page method" fallback) - // tells Playwright/Puppeteer/headless_chrome clients exactly why - // the call failed and what to do instead. - Err( - "Page.printToPDF is not supported by Obscura: no layout engine. \ - Use Runtime.evaluate (e.g. page.evaluate) to extract the rendered \ - HTML, then render to PDF in your client (wkhtmltopdf, weasyprint, \ - a separate headless Chromium pipeline, etc.)." - .to_string(), - ) - } + "printToPDF" => crate::domains::pdf::print_to_pdf(params, ctx, session_id).await, "startScreencast" => { #[cfg(feature = "render")] { - let cdp_session = session_id.as_ref() - .ok_or("Page.startScreencast requires an attached target session")?.clone(); + let cdp_session = session_id + .as_ref() + .ok_or("Page.startScreencast requires an attached target session")? + .clone(); let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") - .ok().and_then(|value| value.parse::().ok()).unwrap_or(3_000); - let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; - let _ = page.prepare_screenshot_resources(resource_deadline_ms).await; + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(3_000); + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + let _ = page + .prepare_screenshot_resources(resource_deadline_ms) + .await; let stream_id = ctx.next_screencast_session(); let state = parse_screencast_state(params, stream_id)?; ctx.screencasts.insert(cdp_session.clone(), state); let pending_before = ctx.pending_events.len(); ctx.pending_events.push(CdpEvent { method: "Page.screencastVisibilityChanged".into(), - params: json!({"visible": true}), session_id: session_id.clone(), + params: json!({"visible": true}), + session_id: session_id.clone(), }); if let Err(error) = queue_screencast_frame(ctx, session_id, true) { ctx.pending_events.truncate(pending_before); ctx.screencasts.remove(&cdp_session); return Err(error); } - tracing::debug!(cdp_session, stream_id, - "started command-driven screencast; autonomous compositor frames unavailable"); + tracing::debug!( + cdp_session, + stream_id, + "started command-driven screencast; autonomous compositor frames unavailable" + ); // Extension fields expose the MVP boundary to direct callers. - Ok(json!({"obscuraFrameSource": "command-driven", "obscuraAutonomousFrames": false})) + Ok( + json!({"obscuraFrameSource": "command-driven", "obscuraAutonomousFrames": false}), + ) } #[cfg(not(feature = "render"))] Err("Page.startScreencast requires a build with the render feature".into()) @@ -984,7 +1137,9 @@ pub async fn handle( "stopScreencast" => { #[cfg(feature = "render")] { - if let Some(cdp_session) = session_id.as_ref() { ctx.screencasts.remove(cdp_session); } + if let Some(cdp_session) = session_id.as_ref() { + ctx.screencasts.remove(cdp_session); + } Ok(json!({})) } #[cfg(not(feature = "render"))] @@ -1018,12 +1173,6 @@ pub async fn handle( .to_string(), ); } - if options.capture_beyond_viewport && options.clip.is_none() { - return Err( - "Page.captureScreenshot full-page captureBeyondViewport=true is not supported without a clip: Obscura cannot yet record an off-viewport surface without relayout or scripted scrolling" - .to_string(), - ); - } if options.format == ScreenshotFormat::Webp && options.quality_supplied { return Err( "WebP screenshot quality is not supported by the current lossless encoder" @@ -1035,30 +1184,81 @@ pub async fn handle( .get_session_page_mut(session_id) .ok_or("No page for session")?; let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") - .ok().and_then(|value| value.parse::().ok()).unwrap_or(3_000); - let _ = page.prepare_screenshot_resources(resource_deadline_ms).await; + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(3_000); + let _ = page + .prepare_screenshot_resources(resource_deadline_ms) + .await; let viewport = page.viewport; - let scroll = if options.clip.is_some() { - page.evaluate("[window.scrollX, window.scrollY]") - .as_array() - .map(|values| { - ( - values.first().and_then(Value::as_f64).unwrap_or(0.0), - values.get(1).and_then(Value::as_f64).unwrap_or(0.0), - ) - }) - .unwrap_or((0.0, 0.0)) + let device_scale_factor = f64::from(page.device_scale_factor); + let trusted_scroll = page.screenshot_scroll_offset(); + let scroll = (f64::from(trusted_scroll.0), f64::from(trusted_scroll.1)); + + let region = if let Some(clip) = options.clip { + let relative_x = clip.x - scroll.0; + let relative_y = clip.y - scroll.1; + let epsilon = 1e-6; + if !options.capture_beyond_viewport + && (relative_x < -epsilon + || relative_y < -epsilon + || relative_x + clip.width > f64::from(viewport.0) + epsilon + || relative_y + clip.height > f64::from(viewport.1) + epsilon) + { + return Err(format!( + "Page.captureScreenshot clip lies outside the current viewport surface while captureBeyondViewport=false (visible page rect: x={} y={} width={} height={})", + scroll.0, scroll.1, viewport.0, viewport.1 + )); + } + Some(chromium_clip_region(clip, device_scale_factor)?) + } else if options.capture_beyond_viewport { + let content_size = page.prepared_content_size().ok_or_else(|| { + "Page.captureScreenshot failed: no retained document size".to_string() + })?; + Some(obscura_browser::CaptureRegion::new( + 0.0, + 0.0, + content_size.0.max(viewport.0), + content_size.1.max(viewport.1), + page.device_scale_factor, + )) + } else if page.device_scale_factor != 1.0 { + Some(obscura_browser::CaptureRegion::new( + scroll.0 as f32, + scroll.1 as f32, + viewport.0, + viewport.1, + page.device_scale_factor, + )) } else { - (0.0, 0.0) + None + }; + + let png = match region { + Some(region) => page + .screenshot_region(region) + .map_err(capture_error_message)?, + None => { + obscura_browser::validate_capture_region( + obscura_browser::CaptureRegion::new( + scroll.0 as f32, + scroll.1 as f32, + viewport.0, + viewport.1, + 1.0, + ), + ) + .map_err(capture_error_message)?; + page.screenshot(viewport).ok_or_else(|| { + "Page.captureScreenshot failed: the page has no DOM to render" + .to_string() + })? + } }; - let png = page.screenshot(viewport).ok_or_else(|| { - "Page.captureScreenshot failed: the page has no DOM to render".to_string() - })?; // Keep the common path allocation-free and byte-for-byte // compatible with the renderer's native PNG encoder. let encoded = if options.format == ScreenshotFormat::Png - && options.clip.is_none() && !options.optimize_for_speed { png @@ -1068,31 +1268,7 @@ pub async fn handle( format!("Page.captureScreenshot could not decode renderer PNG: {error}") })? .to_rgba8(); - - let raster = if let Some(clip) = options.clip { - let relative_x = clip.x - scroll.0; - let relative_y = clip.y - scroll.1; - let epsilon = 1e-6; - if relative_x < -epsilon - || relative_y < -epsilon - || relative_x + clip.width > f64::from(source.width()) + epsilon - || relative_y + clip.height > f64::from(source.height()) + epsilon - { - return Err(format!( - "Page.captureScreenshot clip lies outside the current viewport surface; Obscura cannot yet record off-viewport pixels even with captureBeyondViewport=true (visible page rect: x={} y={} width={} height={})", - scroll.0, scroll.1, viewport.0, viewport.1 - )); - } - sample_screenshot_region( - &source, - clip, - relative_x.max(0.0), - relative_y.max(0.0), - )? - } else { - source - }; - encode_screenshot(&raster, options)? + encode_screenshot(&source, options)? }; use base64::Engine as _; @@ -1100,10 +1276,7 @@ pub async fn handle( Ok(json!({ "data": data })) } #[cfg(not(feature = "render"))] - Err( - "Page.captureScreenshot requires a build with the render feature" - .to_string(), - ) + Err("Page.captureScreenshot requires a build with the render feature".to_string()) } "captureSnapshot" => { // A DOM/layer-tree snapshot (not a raster image). Distinct from @@ -1204,7 +1377,10 @@ mod tests { assert_eq!(metrics["visualViewport"]["pageY"].as_f64(), Some(80.0)); assert_eq!(metrics["contentSize"]["width"].as_f64(), Some(100.0)); assert!( - metrics["contentSize"]["height"].as_f64().unwrap_or_default() >= 160.0, + metrics["contentSize"]["height"] + .as_f64() + .unwrap_or_default() + >= 160.0, "contentSize must expose the scrollable document: {metrics}" ); @@ -1261,13 +1437,23 @@ mod tests { async fn screencast_initial_frame_metadata_and_encoding_match_options() { let (mut ctx, session) = screenshot_fixture().await; ctx.pending_events.clear(); - let result = handle("startScreencast", &json!({ - "format": "jpeg", "quality": 35, "maxWidth": 50, "maxHeight": 100, - }), &mut ctx, &session).await.expect("start screencast"); + let result = handle( + "startScreencast", + &json!({ + "format": "jpeg", "quality": 35, "maxWidth": 50, "maxHeight": 100, + }), + &mut ctx, + &session, + ) + .await + .expect("start screencast"); assert_eq!(result["obscuraFrameSource"], "command-driven"); assert_eq!(result["obscuraAutonomousFrames"], false); assert_eq!(ctx.pending_events.len(), 2); - assert_eq!(ctx.pending_events[0].method, "Page.screencastVisibilityChanged"); + assert_eq!( + ctx.pending_events[0].method, + "Page.screencastVisibilityChanged" + ); let frame = &ctx.pending_events[1]; assert_eq!(frame.method, "Page.screencastFrame"); assert_eq!(frame.session_id, session); @@ -1277,7 +1463,12 @@ mod tests { assert_eq!(frame.params["metadata"]["deviceWidth"], 100.0); assert_eq!(frame.params["metadata"]["deviceHeight"], 80.0); assert_eq!(frame.params["metadata"]["scrollOffsetY"], 0.0); - assert!(frame.params["metadata"]["timestamp"].as_f64().unwrap_or(0.0) > 0.0); + assert!( + frame.params["metadata"]["timestamp"] + .as_f64() + .unwrap_or(0.0) + > 0.0 + ); assert_eq!(frame.params["sessionId"], 1); } @@ -1286,30 +1477,60 @@ mod tests { async fn screencast_sampling_backpressure_and_stale_acks_are_bounded() { let (mut ctx, session) = screenshot_fixture().await; ctx.pending_events.clear(); - handle("startScreencast", &json!({"everyNthFrame": 2}), &mut ctx, &session) - .await.expect("start sampled stream"); + handle( + "startScreencast", + &json!({"everyNthFrame": 2}), + &mut ctx, + &session, + ) + .await + .expect("start sampled stream"); let old_id = ctx.pending_events[1].params["sessionId"].as_i64().unwrap(); ctx.pending_events.clear(); assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap()); assert!(queue_screencast_frame(&mut ctx, &session, false).unwrap()); - assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap(), - "two unacknowledged frames must apply backpressure before capture"); + assert!( + !queue_screencast_frame(&mut ctx, &session, false).unwrap(), + "two unacknowledged frames must apply backpressure before capture" + ); let key = session.as_ref().unwrap(); - handle("screencastFrameAck", &json!({"sessionId": old_id + 99}), &mut ctx, &session) - .await.expect("stale ack"); + handle( + "screencastFrameAck", + &json!({"sessionId": old_id + 99}), + &mut ctx, + &session, + ) + .await + .expect("stale ack"); assert_eq!(ctx.screencasts[key].frames_in_flight, 2); - handle("screencastFrameAck", &json!({"sessionId": old_id}), &mut ctx, &session) - .await.expect("current ack"); + handle( + "screencastFrameAck", + &json!({"sessionId": old_id}), + &mut ctx, + &session, + ) + .await + .expect("current ack"); assert_eq!(ctx.screencasts[key].frames_in_flight, 1); ctx.pending_events.clear(); - handle("startScreencast", &json!({}), &mut ctx, &session).await.expect("restart"); + handle("startScreencast", &json!({}), &mut ctx, &session) + .await + .expect("restart"); let new_id = ctx.pending_events[1].params["sessionId"].as_i64().unwrap(); assert!(new_id > old_id); - handle("screencastFrameAck", &json!({"sessionId": old_id}), &mut ctx, &session) - .await.expect("old generation ack"); + handle( + "screencastFrameAck", + &json!({"sessionId": old_id}), + &mut ctx, + &session, + ) + .await + .expect("old generation ack"); assert_eq!(ctx.screencasts[key].frames_in_flight, 1); - handle("stopScreencast", &json!({}), &mut ctx, &session).await.expect("stop"); + handle("stopScreencast", &json!({}), &mut ctx, &session) + .await + .expect("stop"); assert!(!ctx.screencasts.contains_key(key)); assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap()); } @@ -1320,9 +1541,9 @@ mod tests { assert!(parse_screencast_state(&json!({"format": "webp"}), 1).is_err()); assert!(parse_screencast_state(&json!({"everyNthFrame": 0}), 1).is_err()); assert!(parse_screencast_state(&json!({"maxWidth": 20.5}), 1).is_err()); - let state = parse_screencast_state( - &json!({"quality": 101, "maxWidth": 0, "maxHeight": -1}), 1, - ).expect("Chromium-compatible fallbacks"); + let state = + parse_screencast_state(&json!({"quality": 101, "maxWidth": 0, "maxHeight": -1}), 1) + .expect("Chromium-compatible fallbacks"); assert_eq!(state.quality, DEFAULT_SCREENSHOT_QUALITY as u8); assert_eq!(state.max_width, None); assert_eq!(state.max_height, None); @@ -1368,6 +1589,40 @@ mod tests { "clip x/y must select the blue half of the live surface: {center:?}" ); + // Empirical Chromium result: the fractional CSS size first becomes a + // 10x9 gfx::Size, then 1.1x output scaling rounds to 11x10 pixels. + let fractional = handle( + "captureScreenshot", + &json!({ + "clip": {"x": 50.0, "y": 0.0, "width": 10.9, "height": 9.9, "scale": 1.1} + }), + &mut ctx, + &session, + ) + .await + .expect("fractional Chromium clip"); + let (_, raster) = decode_capture(&fractional); + assert_eq!(raster.dimensions(), (11, 10)); + + let off_viewport = handle( + "captureScreenshot", + &json!({ + "captureBeyondViewport": true, + "clip": {"x": 0.0, "y": 100.0, "width": 20.0, "height": 20.0, "scale": 1.0} + }), + &mut ctx, + &session, + ) + .await + .expect("off-viewport document-space clip"); + let (_, raster) = decode_capture(&off_viewport); + assert_eq!(raster.dimensions(), (20, 20)); + let center = raster.get_pixel(10, 10).0; + assert!( + center[1] > 80 && center[0] < 50 && center[2] < 50, + "captureBeyondViewport must paint off-viewport document content: {center:?}" + ); + ctx.get_session_page_mut(&session) .expect("page") .evaluate("window.scrollTo(0, 80)"); @@ -1389,6 +1644,20 @@ mod tests { ); } + #[cfg(feature = "render")] + #[tokio::test] + async fn default_capture_rejects_oversized_viewport_before_raster_allocation() { + let (mut ctx, session) = screenshot_fixture().await; + ctx.get_session_page_mut(&session) + .expect("page") + .set_viewport((32_768.0, 32_768.0)); + + let error = handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect_err("a 4 GiB RGBA surface must be rejected before allocation"); + assert!(error.contains("bitmap is too large"), "{error}"); + } + #[cfg(feature = "render")] #[tokio::test] async fn capture_screenshot_encodes_jpeg_and_lossless_webp() { @@ -1468,9 +1737,11 @@ mod tests { assert!(parse_screenshot_options(&json!({"fromSurface": "false"})) .expect_err("fromSurface type") .contains("fromSurface must be a boolean")); - assert!(parse_screenshot_options(&json!({"captureBeyondViewport": 1})) - .expect_err("captureBeyondViewport type") - .contains("captureBeyondViewport must be a boolean")); + assert!( + parse_screenshot_options(&json!({"captureBeyondViewport": 1})) + .expect_err("captureBeyondViewport type") + .contains("captureBeyondViewport must be a boolean") + ); assert_eq!( parse_screenshot_options(&json!({ "clip": {"x": 0, "y": 0, "width": 0, "height": 10, "scale": 1} @@ -1487,8 +1758,12 @@ mod tests { #[cfg(feature = "render")] #[tokio::test] - async fn capture_screenshot_rejects_unrepresented_surfaces() { + async fn capture_screenshot_supports_full_page_but_rejects_unrepresented_surfaces() { let (mut ctx, session) = screenshot_fixture().await; + ctx.get_session_page_mut(&session).expect("page").evaluate( + "Object.defineProperty(globalThis,'innerWidth',{value:4096,configurable:true});\ + Object.defineProperty(globalThis,'innerHeight',{value:4096,configurable:true})", + ); let beyond = handle( "captureScreenshot", &json!({"captureBeyondViewport": true}), @@ -1496,8 +1771,14 @@ mod tests { &session, ) .await - .expect_err("full-page recording is unsupported"); - assert!(beyond.contains("captureBeyondViewport=true"), "{beyond}"); + .expect("full-page document capture"); + let (_, raster) = decode_capture(&beyond); + assert_eq!(raster.dimensions(), (100, 160)); + let bottom = raster.get_pixel(50, 120).0; + assert!( + bottom[1] > 80 && bottom[0] < 50 && bottom[2] < 50, + "full-page capture must include below-fold content: {bottom:?}" + ); let off_surface = handle( "captureScreenshot", @@ -1525,6 +1806,54 @@ mod tests { ); } + #[cfg(feature = "render")] + #[tokio::test] + async fn capture_screenshot_combines_device_and_clip_scale_without_relayout() { + let (mut ctx, session) = screenshot_fixture().await; + crate::domains::emulation::handle( + "setDeviceMetricsOverride", + &json!({ + "width": 100, + "height": 80, + "deviceScaleFactor": 2, + "mobile": false + }), + &mut ctx, + &session, + ) + .await + .expect("device metrics override"); + + let full_viewport = handle("captureScreenshot", &json!({}), &mut ctx, &session) + .await + .expect("2x viewport capture"); + let (_, raster) = decode_capture(&full_viewport); + assert_eq!(raster.dimensions(), (200, 160)); + + let clip = handle( + "captureScreenshot", + &json!({ + "clip": {"x": 50, "y": 0, "width": 20, "height": 10, "scale": 1.5} + }), + &mut ctx, + &session, + ) + .await + .expect("device-scaled clip"); + let (_, raster) = decode_capture(&clip); + assert_eq!( + raster.dimensions(), + (60, 30), + "output scale is clip.scale times deviceScaleFactor" + ); + let center = raster.get_pixel(30, 15).0; + assert!(center[2] > 200 && center[0] < 50, "{center:?}"); + + let page = ctx.get_session_page(&session).expect("page"); + assert_eq!(page.viewport, (100.0, 80.0)); + assert_eq!(page.device_scale_factor, 2.0); + } + #[tokio::test] async fn unknown_page_method_still_errors() { let mut ctx = CdpContext::new(); @@ -1535,27 +1864,24 @@ mod tests { } #[tokio::test] - async fn print_to_pdf_returns_descriptive_unsupported_error() { + async fn print_to_pdf_is_explicit_without_a_renderable_session() { // Regression for #53: Page.printToPDF must be handled explicitly so // Playwright clients receive a descriptive error rather than the // generic "Unknown Page method" fallback. let mut ctx = CdpContext::new(); let err = handle("printToPDF", &json!({}), &mut ctx, &None) .await - .expect_err("printToPDF must error until a real renderer exists"); + .expect_err("printToPDF without a page session must error"); assert!( !err.contains("Unknown Page method"), "printToPDF must NOT fall through to the catch-all: {err}" ); + #[cfg(feature = "render")] + assert!(err.contains("No page for session"), "{err}"); + #[cfg(not(feature = "render"))] assert!( - err.contains("not supported by Obscura"), - "error must clearly state PDF is unsupported: {err}" - ); - // Direct user to a workaround so the message is actionable. - assert!( - err.to_lowercase().contains("evaluate") - || err.to_lowercase().contains("html"), - "error must point to a workaround: {err}" + err.contains("requires a build with the render feature"), + "{err}" ); } @@ -1631,7 +1957,10 @@ mod tests { assert_eq!(info["url"], json!(exp_url)); assert_eq!(info["title"], json!(exp_title)); assert!( - info["url"].as_str().unwrap_or_default().starts_with("data:"), + info["url"] + .as_str() + .unwrap_or_default() + .starts_with("data:"), "url should reflect the navigated page, got {}", info["url"] ); diff --git a/crates/obscura-cdp/src/domains/pdf.rs b/crates/obscura-cdp/src/domains/pdf.rs new file mode 100644 index 0000000..b1ef5f8 --- /dev/null +++ b/crates/obscura-cdp/src/domains/pdf.rs @@ -0,0 +1,361 @@ +#[cfg(feature = "render")] +use serde_json::json; +use serde_json::Value; + +use crate::dispatch::CdpContext; + +#[cfg(feature = "render")] +const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024; + +#[cfg(feature = "render")] +fn base64_encoded_len(raw_len: usize) -> Option { + raw_len.checked_add(2)?.checked_div(3)?.checked_mul(4) +} + +#[cfg(feature = "render")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PdfTransferMode { + ReturnAsBase64, + ReturnAsStream, +} + +#[cfg(feature = "render")] +#[derive(Clone, Copy, Debug)] +struct ParsedPdfOptions { + raster: obscura_browser::RasterPdfOptions, + transfer_mode: PdfTransferMode, + requested_print_background: bool, + requested_tagged_pdf: bool, +} + +#[cfg(feature = "render")] +fn number(params: &Value, name: &str, default: f32) -> Result { + match params.get(name) { + None => Ok(default), + Some(value) => value + .as_f64() + .filter(|value| value.is_finite()) + .map(|value| value as f32) + .ok_or_else(|| format!("Invalid parameters: {name} must be a finite number")), + } +} + +#[cfg(feature = "render")] +fn boolean(params: &Value, name: &str, default: bool) -> Result { + match params.get(name) { + None => Ok(default), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")), + } +} + +#[cfg(feature = "render")] +fn parse_options(params: &Value) -> Result { + if !params.is_object() { + return Err("Invalid parameters: expected an object".to_string()); + } + let unsupported_true = [ + ("displayHeaderFooter", "headers and footers"), + ("preferCSSPageSize", "CSS @page sizing"), + ("generateDocumentOutline", "document outlines"), + ]; + for (name, capability) in unsupported_true { + if boolean(params, name, false)? { + return Err(format!( + "Page.printToPDF does not yet support {capability} ({name}=true)" + )); + } + } + // Current Puppeteer and Playwright always send their false default. The + // raster paginator cannot suppress backgrounds without a second paint + // mode, so accept the protocol shape and disclose the limitation in the + // extension capability fields returned with the result. + let requested_print_background = boolean(params, "printBackground", false)?; + // Puppeteer currently sends true by default. Raster PDFs have no semantic + // structure to tag, but rejecting the request makes the standard client + // unusable. Accept and truthfully report `taggedPdf:false` in the response. + let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?; + let scale = number(params, "scale", 1.0)?; + if (scale - 1.0).abs() > f32::EPSILON { + return Err( + "Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted" + .to_string(), + ); + } + if let Some(value) = params.get("pageRanges") { + let ranges = value + .as_str() + .ok_or("Invalid parameters: pageRanges must be a string")?; + if !ranges.trim().is_empty() { + return Err("Page.printToPDF pageRanges are not yet supported".to_string()); + } + } + for name in ["headerTemplate", "footerTemplate"] { + if let Some(value) = params.get(name) { + let template = value + .as_str() + .ok_or_else(|| format!("Invalid parameters: {name} must be a string"))?; + if !template.is_empty() { + return Err(format!("Page.printToPDF {name} is not yet supported")); + } + } + } + let transfer_mode = match params.get("transferMode") { + None => PdfTransferMode::ReturnAsBase64, + Some(Value::String(value)) => match value.as_str() { + "ReturnAsBase64" => PdfTransferMode::ReturnAsBase64, + "ReturnAsStream" => PdfTransferMode::ReturnAsStream, + _ => return Err("Invalid parameters: unknown transferMode".into()), + }, + Some(_) => return Err("Invalid parameters: transferMode must be a string".into()), + }; + + let defaults = obscura_browser::RasterPdfOptions::default(); + Ok(ParsedPdfOptions { + raster: obscura_browser::RasterPdfOptions { + landscape: boolean(params, "landscape", defaults.landscape)?, + paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?, + paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?, + margin_top_in: number(params, "marginTop", defaults.margin_top_in)?, + margin_bottom_in: number(params, "marginBottom", defaults.margin_bottom_in)?, + margin_left_in: number(params, "marginLeft", defaults.margin_left_in)?, + margin_right_in: number(params, "marginRight", defaults.margin_right_in)?, + }, + transfer_mode, + requested_print_background, + requested_tagged_pdf, + }) +} + +pub async fn print_to_pdf( + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + #[cfg(feature = "render")] + { + let options = parse_options(params)?; + let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(3_000); + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + let _ = page.prepare_screenshot_resources(deadline_ms).await; + let pdf = page + .raster_pdf(options.raster) + .map_err(|error| error.to_string())?; + let mut response = json!({ + "obscuraPrintMode": "screen-raster", + "obscuraPrintBackground": true, + "obscuraRequestedPrintBackground": options.requested_print_background, + "obscuraTaggedPDF": false, + "obscuraRequestedTaggedPDF": options.requested_tagged_pdf, + "obscuraCapabilities": { + "cssPagedMedia": false, + "honorsPrintBackground": false, + "taggedPdf": false, + }, + }); + let mut ignored_options = Vec::new(); + if !options.requested_print_background { + ignored_options.push("printBackground"); + } + if options.requested_tagged_pdf { + ignored_options.push("generateTaggedPDF"); + } + response["obscuraIgnoredOptions"] = json!(ignored_options); + match options.transfer_mode { + PdfTransferMode::ReturnAsBase64 => { + use base64::Engine as _; + let encoded_len = base64_encoded_len(pdf.len()) + .ok_or("Page.printToPDF base64 response size overflow")?; + if encoded_len > MAX_BASE64_PDF_BYTES { + return Err(format!( + "Page.printToPDF base64 response would be {encoded_len} bytes, exceeding the {MAX_BASE64_PDF_BYTES}-byte response limit; use transferMode=ReturnAsStream" + )); + } + response["data"] = + Value::String(base64::engine::general_purpose::STANDARD.encode(pdf)); + } + PdfTransferMode::ReturnAsStream => { + let handle = ctx + .io_streams + .insert(pdf) + .map_err(|error| format!("Page.printToPDF could not open stream: {error}"))?; + response["data"] = Value::String(String::new()); + response["stream"] = Value::String(handle); + } + } + Ok(response) + } + #[cfg(not(feature = "render"))] + { + let _ = (params, ctx, session_id); + Err("Page.printToPDF requires a build with the render feature".to_string()) + } +} + +#[cfg(all(test, feature = "render"))] +mod tests { + use super::*; + use crate::domains::page; + + #[test] + fn parser_accepts_standard_client_defaults_and_rejects_unrepresented_features() { + let standard = parse_options(&json!({ + "transferMode": "ReturnAsStream", + "displayHeaderFooter": false, + "headerTemplate": "", + "footerTemplate": "", + "printBackground": false, + "scale": 1, + "pageRanges": "", + "preferCSSPageSize": false, + "generateTaggedPDF": true, + "generateDocumentOutline": false, + })) + .expect("current Puppeteer defaults must be accepted"); + assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream); + assert!(!standard.requested_print_background); + assert!(standard.requested_tagged_pdf); + + for params in [ + json!({"displayHeaderFooter": true}), + json!({"preferCSSPageSize": true}), + json!({"scale": 0.5}), + json!({"pageRanges": "2-3"}), + json!({"headerTemplate": "title"}), + json!({"generateDocumentOutline": true}), + ] { + assert!(parse_options(¶ms).is_err(), "must reject {params}"); + } + } + + #[test] + fn base64_size_preflight_is_exact_and_overflow_safe() { + assert_eq!(base64_encoded_len(0), Some(0)); + assert_eq!(base64_encoded_len(1), Some(4)); + assert_eq!(base64_encoded_len(2), Some(4)); + assert_eq!(base64_encoded_len(3), Some(4)); + assert_eq!(base64_encoded_len(4), Some(8)); + assert_eq!(base64_encoded_len(usize::MAX), None); + let largest_raw = (MAX_BASE64_PDF_BYTES / 4) * 3; + assert_eq!(base64_encoded_len(largest_raw), Some(MAX_BASE64_PDF_BYTES)); + assert!(base64_encoded_len(largest_raw + 1).unwrap() > MAX_BASE64_PDF_BYTES); + } + + #[tokio::test] + async fn print_to_pdf_returns_paginated_pdf_with_requested_media_box() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = format!("{page_id}-session"); + ctx.sessions.insert(session_id.clone(), page_id); + let session = Some(session_id); + ctx.get_session_page_mut(&session) + .unwrap() + .set_viewport((100.0, 80.0)); + page::handle( + "navigate", + &json!({ + "url": "data:text/html,", + "waitUntil": "load", + }), + &mut ctx, + &session, + ).await.expect("navigate fixture"); + let response = print_to_pdf( + &json!({ + "paperWidth": 4.0, "paperHeight": 6.0, + "marginTop": 0.5, "marginBottom": 0.5, + "marginLeft": 0.5, "marginRight": 0.5, + "printBackground": true, + }), + &mut ctx, + &session, + ) + .await + .expect("raster PDF"); + assert_eq!(response["obscuraPrintMode"], "screen-raster"); + assert_eq!(response["obscuraPrintBackground"], true); + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD + .decode(response["data"].as_str().unwrap()) + .unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + assert!(bytes.ends_with(b"%%EOF\n")); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/MediaBox [0 0 288.000 432.000]")); + assert!( + text.matches("/Subtype /Image").count() >= 2, + "400px tall fixture should paginate at this printable aspect ratio" + ); + + let streamed = page::handle( + "printToPDF", + &json!({ + "transferMode": "ReturnAsStream", + "landscape": false, + "displayHeaderFooter": false, + "headerTemplate": "", + "footerTemplate": "", + "printBackground": false, + "scale": 1, + "paperWidth": 8.5, + "paperHeight": 11, + "marginTop": 0, + "marginBottom": 0, + "marginLeft": 0, + "marginRight": 0, + "pageRanges": "", + "preferCSSPageSize": false, + "generateTaggedPDF": true, + "generateDocumentOutline": false, + }), + &mut ctx, + &session, + ) + .await + .expect("Puppeteer/Playwright-shaped stream request"); + assert_eq!(streamed["data"], ""); + assert_eq!(streamed["obscuraPrintBackground"], true); + assert_eq!(streamed["obscuraRequestedPrintBackground"], false); + assert_eq!(streamed["obscuraTaggedPDF"], false); + assert_eq!(streamed["obscuraRequestedTaggedPDF"], true); + assert_eq!( + streamed["obscuraIgnoredOptions"], + json!(["printBackground", "generateTaggedPDF"]) + ); + let handle = streamed["stream"].as_str().expect("protocol stream handle"); + let mut streamed_bytes = Vec::new(); + loop { + let chunk = crate::domains::io::handle( + "read", + &json!({"handle": handle, "size": 1024}), + &mut ctx, + ) + .await + .expect("read PDF stream"); + streamed_bytes.extend( + base64::engine::general_purpose::STANDARD + .decode(chunk["data"].as_str().unwrap()) + .unwrap(), + ); + if chunk["eof"] == true { + break; + } + } + assert!(streamed_bytes.starts_with(b"%PDF-1.4")); + assert!(streamed_bytes.ends_with(b"%%EOF\n")); + crate::domains::io::handle("close", &json!({"handle": handle}), &mut ctx) + .await + .expect("close PDF stream"); + assert!( + crate::domains::io::handle("read", &json!({"handle": handle}), &mut ctx,) + .await + .is_err(), + "closed PDF handles must release their buffer" + ); + } +} diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 1ff4864..393da5e 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -129,8 +129,11 @@ enum Command { #[arg(long)] selector: Option, - #[arg(long, default_value_t = 5)] - wait: u64, + /// Maximum adaptive post-load settle time in seconds. When supplied + /// explicitly, this is a fixed delay; the default is a 5-second cap + /// that returns once the page is quiescent. + #[arg(long)] + wait: Option, #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))] timeout: u64, @@ -194,10 +197,8 @@ enum Command { #[arg(long)] user_agent: Option, }, - } - #[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)] enum DumpFormat { Html, @@ -221,7 +222,8 @@ enum DumpFormat { } fn print_banner(port: u16) { - println!(r#" + println!( + r#" ____ _ / __ \| | | | | | |__ ___ ___ _ _ _ __ __ _ @@ -231,7 +233,10 @@ fn print_banner(port: u16) { Headless Browser v{} CDP server: ws://127.0.0.1:{}/devtools/browser -"#, env!("OBSCURA_BUILD_VERSION"), port); +"#, + env!("OBSCURA_BUILD_VERSION"), + port + ); } fn select_log_filter(verbose: bool, quiet: bool) -> &'static str { @@ -282,9 +287,11 @@ fn normalize_v8_flags(raw: Option<&str>) -> Option { /// footprint. Together they cut RSS ~18% on heavy pages (ycombinator.com /// 173 MB -> 140 MB) at no measurable speed cost (V8 still JITs hot paths). #[cfg(target_pointer_width = "64")] -const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size"; +const DEFAULT_V8_FLAGS: &str = + "--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size"; #[cfg(not(target_pointer_width = "64"))] -const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size"; +const DEFAULT_V8_FLAGS: &str = + "--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size"; fn effective_v8_flags(user: Option<&str>) -> String { match normalize_v8_flags(user) { @@ -305,10 +312,17 @@ async fn main() -> anyhow::Result<()> { // TZ from the host is respected. // SAFETY: runs before any V8 isolate or worker thread starts, so the env is // effectively single threaded here. - if let Some(tz) = std::env::var("OBSCURA_TIMEZONE").ok().filter(|s| !s.trim().is_empty()) { - unsafe { std::env::set_var("TZ", tz); } + if let Some(tz) = std::env::var("OBSCURA_TIMEZONE") + .ok() + .filter(|s| !s.trim().is_empty()) + { + unsafe { + std::env::set_var("TZ", tz); + } } else if std::env::var_os("TZ").is_none() { - unsafe { std::env::set_var("TZ", "Europe/Berlin"); } + unsafe { + std::env::set_var("TZ", "Europe/Berlin"); + } } let quiet = is_quiet_command(&args.command); @@ -333,19 +347,34 @@ async fn main() -> anyhow::Result<()> { // SAFETY: set_var is unsafe in newer rustc; this runs before any // spawned thread inspects the env, so it's effectively single // threaded at this point. - unsafe { std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); } + unsafe { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + } } let global_proxy = args.proxy.clone(); let stealth = args.stealth; match args.command { - Some(Command::Serve { port, host, proxy, user_agent, workers, max_connections, allow_file_access, storage_dir, quiet: _ }) => { + Some(Command::Serve { + port, + host, + proxy, + user_agent, + workers, + max_connections, + allow_file_access, + storage_dir, + quiet: _, + }) => { // Fall back to OBSCURA_PROXY so a proxy can be supplied without // putting credentials on the command line. The multi-worker load // balancer passes the proxy to each worker this way (issue #366). - let proxy = merge_proxy(global_proxy.clone(), proxy) - .or_else(|| std::env::var("OBSCURA_PROXY").ok().filter(|s| !s.is_empty())); + let proxy = merge_proxy(global_proxy.clone(), proxy).or_else(|| { + std::env::var("OBSCURA_PROXY") + .ok() + .filter(|s| !s.is_empty()) + }); print_banner(port); if let Some(ref dir) = storage_dir { tracing::info!("Storage dir: {}", dir.display()); @@ -370,12 +399,35 @@ async fn main() -> anyhow::Result<()> { run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?; } else { obscura_cdp::start_with_serve_options_and_limit( - port, &host, proxy, stealth, user_agent, allow_file_access, storage_dir, - args.allow_private_network, max_connections, - ).await?; + port, + &host, + proxy, + stealth, + user_agent, + allow_file_access, + storage_dir, + args.allow_private_network, + max_connections, + ) + .await?; } } - Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, eval, output, quiet, storage_dir, file, concurrency, screenshot }) => { + Some(Command::Fetch { + url, + dump, + selector, + wait, + timeout, + wait_until, + user_agent, + eval, + output, + quiet, + storage_dir, + file, + concurrency, + screenshot, + }) => { if let Some(file) = file { if url.is_some() { anyhow::bail!("Pass URLs via a positional argument or --file, not both."); @@ -392,18 +444,71 @@ async fn main() -> anyhow::Result<()> { ), } let urls = read_urls_from_file(&file)?; - run_batch_fetch(urls, concurrency.get(), timeout, user_agent, global_proxy, output, quiet).await?; + run_batch_fetch( + urls, + concurrency.get(), + timeout, + user_agent, + global_proxy, + output, + quiet, + ) + .await?; } else { let url = url.ok_or_else(|| { - anyhow::anyhow!("No URL provided. Pass a URL, or a list of URLs with --file .") + anyhow::anyhow!( + "No URL provided. Pass a URL, or a list of URLs with --file ." + ) })?; - run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network, screenshot).await?; + let wait_is_fixed = wait.is_some(); + run_fetch( + &url, + dump, + selector, + wait.unwrap_or(5), + wait_is_fixed, + timeout, + &wait_until, + user_agent, + stealth, + eval, + output, + quiet, + global_proxy, + storage_dir, + args.allow_private_network, + screenshot, + ) + .await?; } } - Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => { - run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy, stealth).await?; + Some(Command::Scrape { + urls, + eval, + concurrency, + format, + timeout, + quiet, + }) => { + run_parallel_scrape( + urls, + eval, + concurrency.get(), + &format, + timeout, + quiet, + global_proxy, + stealth, + ) + .await?; } - Some(Command::Mcp { http, host, port, proxy, user_agent }) => { + Some(Command::Mcp { + http, + host, + port, + proxy, + user_agent, + }) => { let mcp_proxy = merge_proxy(global_proxy.clone(), proxy); if http { obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?; @@ -431,8 +536,8 @@ async fn run_multi_worker_serve( stealth: bool, user_agent: Option, ) -> anyhow::Result<()> { - use tokio::net::TcpListener; use tokio::io::AsyncWriteExt as _; + use tokio::net::TcpListener; let exe = std::env::current_exe()?; let mut children = Vec::new(); @@ -514,11 +619,8 @@ async fn run_multi_worker_serve( return; } }; - let _ = tokio::io::copy_bidirectional( - &mut client, - &mut worker_stream, - ) - .await; + let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream) + .await; }); } Err(e) => { @@ -526,9 +628,7 @@ async fn run_multi_worker_serve( tokio::spawn(async move { let mut s = client_stream; let _ = s - .write_all( - b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n", - ) + .write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n") .await; let _ = s.shutdown().await; }); @@ -543,8 +643,7 @@ async fn run_multi_worker_serve( match tokio::net::TcpStream::connect(&worker_addr).await { Ok(mut worker_stream) => { let mut client = client_stream; - let _ = - tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await; + let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await; } Err(e) => { tracing::warn!("worker {} unreachable: {}", worker_addr, e); @@ -559,11 +658,21 @@ async fn run_multi_worker_serve( } } +async fn settle_page(page: &mut Page, wait_secs: u64, fixed: bool) { + let wait_ms = wait_secs.saturating_mul(1000); + if fixed { + page.settle_for_duration(wait_ms).await; + } else { + page.settle(wait_ms).await; + } +} + async fn run_fetch( url_str: &str, dump: Option, selector: Option, wait_secs: u64, + wait_is_fixed: bool, timeout_secs: u64, wait_until: &str, user_agent: Option, @@ -587,13 +696,7 @@ async fn run_fetch( // payloads (images, fonts, …) and any non-HTML resource where parsing the // body through the DOM/JS layer would corrupt or discard data. if dump == DumpFormat::Original { - let bytes = fetch_original_bytes( - url_str, - proxy, - user_agent.clone(), - timeout_secs, - ) - .await?; + let bytes = fetch_original_bytes(url_str, proxy, user_agent.clone(), timeout_secs).await?; write_or_print_bytes(&bytes, output.as_ref()).await?; return Ok(()); } @@ -643,8 +746,7 @@ async fn run_fetch( // absent. let eval_at_capture_boundary = screenshot.is_some() && eval.is_some() - && std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE") - .is_ok_and(|value| value == "1"); + && std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE").is_ok_and(|value| value == "1"); let controlled_scroll_request = screenshot.as_ref().and_then(|_| { let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?; let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") { @@ -673,9 +775,7 @@ async fn run_fetch( { let settle_passes = if eval_at_capture_boundary { 1 + u64::from(controlled_scroll_request.is_some()) - } else if eval.is_some() - && (screenshot.is_some() || selector.is_some() || dump_specified) - { + } else if eval.is_some() && (screenshot.is_some() || selector.is_some() || dump_specified) { 2 } else { 1 @@ -687,13 +787,23 @@ async fn run_fetch( ); std::thread::spawn(move || { std::thread::sleep(hard); - eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs()); + eprintln!( + "obscura: hard timeout exceeded ({}s); forcing exit", + hard.as_secs() + ); std::process::exit(124); }); } - match timeout(Duration::from_secs(timeout_secs), page.navigate_with_wait(url_str, wait_condition)).await { - Ok(result) => result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?, + match timeout( + Duration::from_secs(timeout_secs), + page.navigate_with_wait(url_str, wait_condition), + ) + .await + { + Ok(result) => { + result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))? + } Err(_) => anyhow::bail!( "Timed out navigating to {} after {}s", url_str, @@ -709,7 +819,7 @@ async fn run_fetch( // and completion callbacks (e.g. testharness's add_completion_callback) run // before we read the page. Returns early once the loop is idle, so static // pages stay fast. - page.settle(wait_secs.saturating_mul(1000)).await; + settle_page(&mut page, wait_secs, wait_is_fixed).await; let mut deferred_eval_output = None; let initial_controlled_scroll = if eval_at_capture_boundary { @@ -731,7 +841,7 @@ async fn run_fetch( None }; if initial_controlled_scroll.is_some() { - page.settle(wait_secs.saturating_mul(1000)).await; + settle_page(&mut page, wait_secs, wait_is_fixed).await; } if !eval_at_capture_boundary { @@ -764,7 +874,7 @@ async fn run_fetch( // listener) that writes the DOM. Drive the event loop again so that // work completes, then fall through to selector/capture/dump instead // of returning the still-pending eval value (issue #248). - page.settle(wait_secs.saturating_mul(1000)).await; + settle_page(&mut page, wait_secs, wait_is_fixed).await; } } @@ -785,7 +895,9 @@ async fn run_fetch( .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(3_000); - let _ = page.prepare_screenshot_resources(resource_deadline_ms).await; + let _ = page + .prepare_screenshot_resources(resource_deadline_ms) + .await; // Default CSS-pixel viewport, matching the engine's innerWidth/Height. // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // capture below-the-fold content in one shot). @@ -795,8 +907,8 @@ async fn run_fetch( // render resources during prepare/paint, so sampling first would // compare pre-paint Obscura state with post-load Chromium state. // Keep this private opt-in out of ordinary CLI screenshots. - let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP") - .is_ok_and(|value| value == "1"); + let warmup_capture = + std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1"); if warmup_capture { if page.screenshot(viewport).is_none() { anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render"); @@ -836,9 +948,8 @@ async fn run_fetch( }); if eval_at_capture_boundary { if let Some(ref expr) = eval { - deferred_eval_output = Some( - page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)) - ); + deferred_eval_output = + Some(page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs))); } } let capture_state = deferred_eval_output.as_ref().map(|_| { @@ -896,7 +1007,11 @@ async fn run_fetch( ); } if !quiet { - eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)); + eprintln!( + "Screenshot written: {} ({} bytes)", + path.display(), + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) + ); } context.save_cookies(); return Ok(()); @@ -958,7 +1073,11 @@ async fn fetch_original_bytes( user_agent: Option, timeout_secs: u64, ) -> anyhow::Result> { - Ok(fetch_original_response(url_str, proxy, user_agent, timeout_secs).await?.body) + Ok( + fetch_original_response(url_str, proxy, user_agent, timeout_secs) + .await? + .body, + ) } /// Read newline-delimited URLs from `path` (or stdin when `path` is `-`). @@ -1024,9 +1143,13 @@ async fn run_batch_fetch( handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.unwrap(); let task_start = Instant::now(); - let result = - fetch_original_response(&url, (*proxy).clone(), (*user_agent).clone(), timeout_secs) - .await; + let result = fetch_original_response( + &url, + (*proxy).clone(), + (*user_agent).clone(), + timeout_secs, + ) + .await; let elapsed_ms = task_start.elapsed().as_millis(); let line = match result { @@ -1097,7 +1220,10 @@ async fn run_batch_fetch( Ok(()) } -async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> { +async fn write_or_print( + content: String, + output: Option<&std::path::PathBuf>, +) -> anyhow::Result<()> { if let Some(path) = output { tokio::fs::write(path, content) .await @@ -1135,9 +1261,9 @@ async fn write_or_print_bytes( async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); loop { - let found = page.with_dom(|dom| { - dom.query_selector(selector).ok().flatten().is_some() - }).unwrap_or(false); + let found = page + .with_dom(|dom| dom.query_selector(selector).ok().flatten().is_some()) + .unwrap_or(false); if found { return true; @@ -1176,7 +1302,8 @@ fn dump_html(page: &Page) -> String { let doc = dom.document(); dom.inner_html(doc) } - }).unwrap_or_default() + }) + .unwrap_or_default() } fn dump_text(page: &mut Page) -> String { @@ -1187,7 +1314,8 @@ fn dump_text(page: &mut Page) -> String { } else { String::new() } - }).unwrap_or_default() + }) + .unwrap_or_default() } fn dump_markdown(page: &mut Page) -> String { @@ -1249,18 +1377,47 @@ fn extract_readable_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeI // Boilerplate elements rarely contain content the user wants to // scrape — strip them so `--dump text` returns the article body // instead of menus, footers, and cookie banners. - if matches!(tag, "script" | "style" | "nav" | "header" | "footer" | "aside") { + if matches!( + tag, + "script" | "style" | "nav" | "header" | "footer" | "aside" + ) { continue; } let is_block = matches!( tag, - "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" - | "li" | "tr" | "br" | "hr" | "blockquote" | "pre" - | "section" | "article" | "header" | "footer" | "nav" - | "main" | "aside" | "figure" | "figcaption" | "table" - | "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd" - | "ul" | "ol" + "div" + | "p" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "li" + | "tr" + | "br" + | "hr" + | "blockquote" + | "pre" + | "section" + | "article" + | "header" + | "footer" + | "nav" + | "main" + | "aside" + | "figure" + | "figcaption" + | "table" + | "thead" + | "tbody" + | "tfoot" + | "dl" + | "dt" + | "dd" + | "ul" + | "ol" ); if is_block { @@ -1308,7 +1465,11 @@ async fn run_parallel_scrape( ); } - let worker_name = if cfg!(windows) { "obscura-worker.exe" } else { "obscura-worker" }; + let worker_name = if cfg!(windows) { + "obscura-worker.exe" + } else { + "obscura-worker" + }; let worker_path = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join(worker_name))) @@ -1382,44 +1543,10 @@ async fn run_parallel_scrape( }; let mut reader = BufReader::new(stdout); - let worker_result: Result = match timeout(worker_timeout, async { - let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url}); - let mut line = serde_json::to_string(&nav_cmd).unwrap(); - line.push('\n'); - if stdin.write_all(line.as_bytes()).await.is_err() { - return Err("Write failed".to_string()); - } - if stdin.flush().await.is_err() { - return Err("Write failed".to_string()); - } - - let mut resp_line = String::new(); - match timeout(read_timeout, reader.read_line(&mut resp_line)).await { - Ok(Ok(bytes)) if bytes > 0 => {} - Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()), - Err(_) => return Err("timeout".to_string()), - }; - - let nav_resp: serde_json::Value = - serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false})); - - if !nav_resp["ok"].as_bool().unwrap_or(false) { - return Err( - nav_resp["error"] - .as_str() - .unwrap_or("navigate failed") - .to_string(), - ); - } - - let title = nav_resp["result"]["title"] - .as_str() - .unwrap_or("") - .to_string(); - - let eval_result = if let Some(ref expr) = *eval { - let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr}); - let mut line = serde_json::to_string(&eval_cmd).unwrap(); + let worker_result: Result = + match timeout(worker_timeout, async { + let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url}); + let mut line = serde_json::to_string(&nav_cmd).unwrap(); line.push('\n'); if stdin.write_all(line.as_bytes()).await.is_err() { return Err("Write failed".to_string()); @@ -1430,39 +1557,73 @@ async fn run_parallel_scrape( let mut resp_line = String::new(); match timeout(read_timeout, reader.read_line(&mut resp_line)).await { - Ok(Ok(bytes)) if bytes > 0 => { - let resp: serde_json::Value = serde_json::from_str(resp_line.trim()) - .unwrap_or(serde_json::json!({"ok": false})); - resp["result"].clone() - } + Ok(Ok(bytes)) if bytes > 0 => {} Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()), Err(_) => return Err("timeout".to_string()), + }; + + let nav_resp: serde_json::Value = serde_json::from_str(resp_line.trim()) + .unwrap_or(serde_json::json!({"ok": false})); + + if !nav_resp["ok"].as_bool().unwrap_or(false) { + return Err(nav_resp["error"] + .as_str() + .unwrap_or("navigate failed") + .to_string()); } - } else { - serde_json::Value::Null + + let title = nav_resp["result"]["title"] + .as_str() + .unwrap_or("") + .to_string(); + + let eval_result = if let Some(ref expr) = *eval { + let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr}); + let mut line = serde_json::to_string(&eval_cmd).unwrap(); + line.push('\n'); + if stdin.write_all(line.as_bytes()).await.is_err() { + return Err("Write failed".to_string()); + } + if stdin.flush().await.is_err() { + return Err("Write failed".to_string()); + } + + let mut resp_line = String::new(); + match timeout(read_timeout, reader.read_line(&mut resp_line)).await { + Ok(Ok(bytes)) if bytes > 0 => { + let resp: serde_json::Value = + serde_json::from_str(resp_line.trim()) + .unwrap_or(serde_json::json!({"ok": false})); + resp["result"].clone() + } + Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()), + Err(_) => return Err("timeout".to_string()), + } + } else { + serde_json::Value::Null + }; + + let shutdown_cmd = serde_json::json!({"cmd": "shutdown"}); + let mut line = serde_json::to_string(&shutdown_cmd).unwrap(); + line.push('\n'); + let _ = stdin.write_all(line.as_bytes()).await; + let _ = stdin.flush().await; + let _ = timeout(shutdown_timeout, child.wait()).await; + + Ok(serde_json::json!({ + "url": url, + "title": title, + "eval": eval_result, + "time_ms": task_start.elapsed().as_millis(), + "worker": i, + })) + }) + .await + { + Ok(result) => result, + Err(_) => Err("timeout".to_string()), }; - let shutdown_cmd = serde_json::json!({"cmd": "shutdown"}); - let mut line = serde_json::to_string(&shutdown_cmd).unwrap(); - line.push('\n'); - let _ = stdin.write_all(line.as_bytes()).await; - let _ = stdin.flush().await; - let _ = timeout(shutdown_timeout, child.wait()).await; - - Ok(serde_json::json!({ - "url": url, - "title": title, - "eval": eval_result, - "time_ms": task_start.elapsed().as_millis(), - "worker": i, - })) - }) - .await - { - Ok(result) => result, - Err(_) => Err("timeout".to_string()), - }; - match worker_result { Ok(result) => result, Err(error) => { @@ -1537,7 +1698,9 @@ fn dump_links(page: &Page) -> String { let full_url = if href.starts_with("http://") || href.starts_with("https://") { href.clone() } else if let Some(ref base) = base_url { - base.join(&href).map(|u| u.to_string()).unwrap_or(href.clone()) + base.join(&href) + .map(|u| u.to_string()) + .unwrap_or(href.clone()) } else { href.clone() }; @@ -1552,7 +1715,8 @@ fn dump_links(page: &Page) -> String { } } rendered.join("\n") - }).unwrap_or_default() + }) + .unwrap_or_default() } /// Selectors paired with the attribute whose URL we extract and the @@ -1575,7 +1739,13 @@ const ASSET_SELECTORS: &[(&str, &str, &str)] = &[ /// Unknown / missing `rel` falls back to a generic "link" so the /// caller still sees the URL. fn link_kind_from_rel(rel: &str) -> &'static str { - match rel.split_ascii_whitespace().next().unwrap_or("").to_ascii_lowercase().as_str() { + match rel + .split_ascii_whitespace() + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { "stylesheet" => "stylesheet", "icon" | "shortcut" => "icon", "manifest" => "manifest", @@ -1616,9 +1786,13 @@ fn extract_assets(dom: &obscura_dom::DomTree, base_url: Option<&url::Url>) -> St for (selector, attr, default_kind) in ASSET_SELECTORS { let nodes = dom.query_selector_all(selector).unwrap_or_default(); for node_id in nodes { - let Some(node) = dom.get_node(node_id) else { continue }; + let Some(node) = dom.get_node(node_id) else { + continue; + }; let raw = node.get_attribute(attr).unwrap_or_default().to_string(); - let Some(url) = resolve_asset_url(&raw, base_url) else { continue }; + let Some(url) = resolve_asset_url(&raw, base_url) else { + continue; + }; let kind = if *default_kind == "link" { let rel = node.get_attribute("rel").unwrap_or_default().to_string(); @@ -1643,8 +1817,11 @@ fn dump_assets(page: &Page) -> String { .with_dom(|dom| extract_assets(dom, base_url.as_ref())) .unwrap_or_default(); - let mut lines: Vec = - dom_ndjson.lines().filter(|l| !l.is_empty()).map(|l| l.to_string()).collect(); + let mut lines: Vec = dom_ndjson + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); // URLs already listed from static DOM attributes, so a resource the script // fetches that the markup also references is not emitted twice. @@ -1669,9 +1846,9 @@ fn dump_assets(page: &Page) -> String { mod tests { use super::{ effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes, - is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, - read_urls_from_file, resolve_asset_url, select_log_filter, write_or_print, - write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS, + is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, read_urls_from_file, + resolve_asset_url, select_log_filter, write_or_print, write_or_print_bytes, Args, Command, + DumpFormat, DEFAULT_V8_FLAGS, }; use clap::Parser; use obscura_dom::parse_html; @@ -1720,7 +1897,13 @@ mod tests { ]) .expect("clap should accept --file with --concurrency and no positional URL"); match args.command { - Some(Command::Fetch { url, file, concurrency, dump, .. }) => { + Some(Command::Fetch { + url, + file, + concurrency, + dump, + .. + }) => { assert!(url.is_none()); assert_eq!(file, Some(std::path::PathBuf::from("urls.txt"))); assert_eq!(concurrency.get(), 25); @@ -1734,7 +1917,8 @@ mod tests { fn concurrency_rejects_zero() { // NonZeroUsize means --concurrency 0 is a parse error, not a silent hang // on a zero-permit semaphore. - let err = Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]); + let err = + Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]); assert!(err.is_err()); } @@ -1788,7 +1972,10 @@ mod tests { let _ = tokio::fs::remove_file(&path).await; - assert_eq!(bytes, PNG_BYTES, "raw response body must match the file byte-for-byte"); + assert_eq!( + bytes, PNG_BYTES, + "raw response body must match the file byte-for-byte" + ); } #[tokio::test(flavor = "current_thread")] @@ -1811,7 +1998,10 @@ mod tests { let read_back = tokio::fs::read(&path).await.expect("read back"); let _ = tokio::fs::remove_file(&path).await; - assert_eq!(read_back, payload, "file bytes must match the payload exactly"); + assert_eq!( + read_back, payload, + "file bytes must match the payload exactly" + ); } #[tokio::test(flavor = "current_thread")] @@ -1856,13 +2046,8 @@ mod tests { #[test] fn parsed_fetch_with_quiet_flag_is_detected() { - let args = Args::try_parse_from([ - "obscura", - "fetch", - "--quiet", - "https://example.com", - ]) - .expect("clap should accept --quiet on fetch"); + let args = Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"]) + .expect("clap should accept --quiet on fetch"); assert!(is_quiet_command(&args.command)); } @@ -1875,8 +2060,7 @@ mod tests { #[test] fn parsed_serve_command_is_not_quiet() { - let args = Args::try_parse_from(["obscura", "serve"]) - .expect("clap should accept serve"); + let args = Args::try_parse_from(["obscura", "serve"]).expect("clap should accept serve"); assert!(!is_quiet_command(&args.command)); } @@ -1938,14 +2122,9 @@ mod tests { #[test] fn parsed_v8_flags_empty_string_is_accepted() { - let args = Args::try_parse_from([ - "obscura", - "--v8-flags", - "", - "fetch", - "https://example.com", - ]) - .expect("clap should accept empty --v8-flags value"); + let args = + Args::try_parse_from(["obscura", "--v8-flags", "", "fetch", "https://example.com"]) + .expect("clap should accept empty --v8-flags value"); assert_eq!(args.v8_flags.as_deref(), Some("")); } @@ -2001,17 +2180,29 @@ mod tests { #[test] fn parsed_fetch_quiet_resolves_to_off_filter() { - let args = Args::try_parse_from([ - "obscura", - "fetch", - "--quiet", - "https://example.com", - ]) - .unwrap(); + let args = + Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"]).unwrap(); let filter = select_log_filter(args.verbose, is_quiet_command(&args.command)); assert_eq!(filter, "off"); } + #[test] + fn fetch_wait_distinguishes_adaptive_default_from_fixed_delay() { + let default = Args::try_parse_from(["obscura", "fetch", "https://example.com"]).unwrap(); + match default.command { + Some(Command::Fetch { wait, .. }) => assert_eq!(wait, None), + _ => panic!("expected Fetch command"), + } + + let fixed = + Args::try_parse_from(["obscura", "fetch", "https://example.com", "--wait", "0"]) + .unwrap(); + match fixed.command { + Some(Command::Fetch { wait, .. }) => assert_eq!(wait, Some(0)), + _ => panic!("expected Fetch command"), + } + } + #[test] fn matcher_still_uses_fetch_variant() { let cmd = Some(Command::Fetch { @@ -2020,7 +2211,7 @@ mod tests { selector: None, file: None, concurrency: std::num::NonZeroUsize::new(1).unwrap(), - wait: 5, + wait: Some(5), timeout: 30, wait_until: "load".to_string(), user_agent: None, @@ -2040,7 +2231,10 @@ mod tests { .ok() .flatten() .expect("body must exist"); - extract_readable_text(&dom, body).split_whitespace().collect::>().join(" ") + extract_readable_text(&dom, body) + .split_whitespace() + .collect::>() + .join(" ") } #[test] @@ -2267,5 +2461,4 @@ mod tests { assert!(lines[0].contains("\"https://example.test/ok.html\"")); assert!(lines[0].contains("\"iframe\"")); } - } diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index e8e2176..f4916fb 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -9,8 +9,8 @@ use selectors::matching::{ NeedsSelectorFlags, }; use selectors::parser::{self, ParseRelative, SelectorParseErrorKind}; -use selectors::{Element, OpaqueElement, SelectorList}; use selectors::visitor::SelectorVisitor; +use selectors::{Element, OpaqueElement, SelectorList}; use crate::tree::{DomTree, NodeData, NodeId}; @@ -251,7 +251,13 @@ impl<'a> DomElement<'a> { .map(|name| { matches!( name.local.as_ref(), - "input" | "button" | "select" | "textarea" | "optgroup" | "option" | "fieldset" + "input" + | "button" + | "select" + | "textarea" + | "optgroup" + | "option" + | "fieldset" ) }) .unwrap_or(false) @@ -390,20 +396,26 @@ impl<'a> Element for DomElement<'a> { fn has_namespace(&self, ns: &CssNamespace) -> bool { self.tree .with_node(self.node_id, |n| { - n.as_element() - .map(|name| name.ns == ns.0) - .unwrap_or(false) + n.as_element().map(|name| name.ns == ns.0).unwrap_or(false) }) .unwrap_or(false) } fn is_same_type(&self, other: &Self) -> bool { - let self_name = self.tree.with_node(self.node_id, |n| { - n.as_element().map(|name| (name.local.clone(), name.ns.clone())) - }).flatten(); - let other_name = self.tree.with_node(other.node_id, |n| { - n.as_element().map(|name| (name.local.clone(), name.ns.clone())) - }).flatten(); + let self_name = self + .tree + .with_node(self.node_id, |n| { + n.as_element() + .map(|name| (name.local.clone(), name.ns.clone())) + }) + .flatten(); + let other_name = self + .tree + .with_node(other.node_id, |n| { + n.as_element() + .map(|name| (name.local.clone(), name.ns.clone())) + }) + .flatten(); match (self_name, other_name) { (Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns, _ => false, @@ -441,10 +453,9 @@ impl<'a> Element for DomElement<'a> { .with_node(self.node_id, |node| { node.attrs() .map(|attrs| { - attrs.iter().any(|a| { - a.name.ns == html5ever::ns!() - && a.name.local == local_name.0 - }) + attrs + .iter() + .any(|a| a.name.ns == html5ever::ns!() && a.name.local == local_name.0) }) .unwrap_or(false) }) @@ -469,7 +480,9 @@ impl<'a> Element for DomElement<'a> { // correctly are) made every such base rule silently inert. PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"), PseudoClass::Disabled => self.is_form_control() && self.has_boolean_attr("disabled"), - PseudoClass::Checked => self.has_boolean_attr("checked") || self.has_boolean_attr("selected"), + PseudoClass::Checked => { + self.has_boolean_attr("checked") || self.has_boolean_attr("selected") + } // Dynamic user-interaction pseudo-classes have no meaning against // a static DOM snapshot with no live user input. PseudoClass::Hover @@ -640,7 +653,10 @@ fn simple_id_selector(selector: &str) -> Option<&str> { if !(first.is_ascii_alphabetic() || first == b'_') { return None; } - if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { + if id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { Some(id) } else { None @@ -656,7 +672,11 @@ impl DomTree { self.query_selector_all_from(self.document(), selector) } - pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result, String> { + pub fn query_selector_from( + &self, + root: NodeId, + selector: &str, + ) -> Result, String> { // Fast path: a bare "#id" selector resolves through the id index in O(1) // instead of scanning every descendant. The index holds the first element // in tree order per id, which is exactly what the full scan would return. @@ -714,7 +734,11 @@ impl DomTree { } } - pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result, String> { + pub fn query_selector_all_from( + &self, + root: NodeId, + selector: &str, + ) -> Result, String> { let selector_list = parse_selector(selector)?; let mut caches = selectors::context::SelectorCaches::default(); let mut context = MatchingContext::new( @@ -746,7 +770,10 @@ impl DomTree { /// Test one element as the selector subject, including when it is /// detached or is a direct child of a shadow-tree compatibility root. pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result { - if !self.with_node(nid, |node| node.is_element()).unwrap_or(false) { + if !self + .with_node(nid, |node| node.is_element()) + .unwrap_or(false) + { return Ok(false); } let selector_list = parse_selector(selector)?; @@ -768,7 +795,7 @@ impl DomTree { /// Parse a single selector once for repeated single-element matching, and /// precompute its specificity, ancestor hashes, and "subject key" (the - /// rightmost id/class/tag used to bucket the rule for fast candidate + /// rightmost id/class/attribute/tag used to bucket the rule for fast candidate /// lookup). Returns `None` if the selector fails to parse. /// /// This is the primitive a stylesheet cascade builds on: compile every rule @@ -778,9 +805,14 @@ impl DomTree { let list = parse_selector(selector).ok()?; let sel = list.slice().first()?.clone(); let specificity = sel.specificity(); - let key = subject_key(&sel); + let keys = SubjectKeys::from_vec(subject_keys(&sel)); let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks); - Some(CompiledSelector { sel, specificity, key, hashes }) + Some(CompiledSelector { + sel, + specificity, + keys, + hashes, + }) } /// Does a single element match a precompiled selector? Allocates a fresh @@ -798,6 +830,8 @@ impl DomTree { Matcher { caches: selectors::context::SelectorCaches::default(), ancestors: AncestorFilter::new(), + candidate_generation: 0, + candidate_seen: Vec::new(), } } } @@ -809,9 +843,41 @@ impl DomTree { pub struct Matcher { caches: selectors::context::SelectorCaches, ancestors: AncestorFilter, + candidate_generation: u32, + candidate_seen: Vec, } impl Matcher { + /// Start one indexed-candidate collection without clearing the reusable + /// seen table. Stylesheet rules that live in several selector buckets use + /// this to avoid matching and cascading the same rule twice. + #[doc(hidden)] + pub fn begin_candidate_collection(&mut self, candidate_count: usize) { + self.candidate_generation = self.candidate_generation.wrapping_add(1); + if self.candidate_generation == 0 { + self.candidate_seen.fill(0); + self.candidate_generation = 1; + } + self.candidate_seen.resize(candidate_count, 0); + } + + /// Return true once per candidate index in the current collection. + #[doc(hidden)] + pub fn mark_candidate(&mut self, index: usize) -> bool { + debug_assert!(index < self.candidate_seen.len()); + let Some(seen) = self.candidate_seen.get_mut(index) else { + // Candidate collection is an optimization. If a future caller + // supplies inconsistent bounds, fail open to an extra match rather + // than silently dropping authored CSS. + return true; + }; + if *seen == self.candidate_generation { + return false; + } + *seen = self.candidate_generation; + true + } + /// Match `nid` (matched as if it were the subject element; the ancestor /// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`. pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool { @@ -827,7 +893,13 @@ impl Matcher { MatchingForInvalidation::No, ); let element = DomElement::new(tree, nid); - selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context) + selectors::matching::matches_selector( + &compiled.sel, + 0, + Some(&compiled.hashes), + &element, + &mut context, + ) } /// Push `nid`'s hashes onto the ancestor filter before descending into its @@ -854,7 +926,10 @@ struct AncestorFilter { impl AncestorFilter { fn new() -> Self { - AncestorFilter { filter: Box::default(), levels: Vec::new() } + AncestorFilter { + filter: Box::default(), + levels: Vec::new(), + } } fn filter(&self) -> &selectors::bloom::BloomFilter { @@ -865,13 +940,25 @@ impl AncestorFilter { let mut hashes = Vec::new(); tree.with_node(nid, |node| { if let Some(elem) = node.as_element() { - push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash()); + push_hash( + &mut hashes, + &mut self.filter, + CssLocalName(elem.local.clone()).precomputed_hash(), + ); if let Some(id) = node.get_attribute("id") { - push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash()); + push_hash( + &mut hashes, + &mut self.filter, + CssString(id.to_string()).precomputed_hash(), + ); } if let Some(class) = node.get_attribute("class") { for c in class.split_whitespace() { - push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash()); + push_hash( + &mut hashes, + &mut self.filter, + CssString(c.to_string()).precomputed_hash(), + ); } } } @@ -898,8 +985,13 @@ fn push_hash(hashes: &mut Vec, filter: &mut selectors::bloom::BloomFilter, /// only tested against elements that carry its key. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum SelectorKey { + /// The document element. `:root` can match only this one subject, so it is + /// substantially more selective than the universal bucket despite having + /// no id/class/tag token. + Root, Id(String), Class(String), + Attribute(String), Local(String), Universal, } @@ -908,51 +1000,359 @@ pub enum SelectorKey { pub struct CompiledSelector { sel: parser::Selector, specificity: u32, - key: SelectorKey, + keys: SubjectKeys, hashes: parser::AncestorHashes, } impl CompiledSelector { + /// The one bucket usable by legacy selector indexes. Selectors whose + /// subject has multiple disjoint alternatives deliberately report + /// `Universal`; use [`Self::candidate_keys`] to opt into multi-bucket + /// indexing. pub fn key(&self) -> &SelectorKey { - &self.key + match &self.keys { + SubjectKeys::One(key) => key, + // Existing single-bucket consumers must keep treating disjoint + // alternatives as universal until they opt into `candidate_keys`. + SubjectKeys::Many(_) => &SelectorKey::Universal, + } + } + + /// Deduplicated buckets that together cover every element this selector + /// can match. A consumer must insert the rule in every returned bucket and + /// deduplicate rule candidates before matching, since one element can + /// carry keys from more than one alternative. + pub fn candidate_keys(&self) -> &[SelectorKey] { + match &self.keys { + SubjectKeys::One(key) => std::slice::from_ref(key), + SubjectKeys::Many(keys) => keys, + } } pub fn specificity(&self) -> u32 { self.specificity } } -/// Pick the index key from a selector's rightmost compound: prefer id, then -/// class, then local name; fall back to Universal for `*`, attribute-only, or -/// pseudo-only subjects. -fn subject_key(sel: &parser::Selector) -> SelectorKey { +enum SubjectKeys { + One(SelectorKey), + Many(Box<[SelectorKey]>), +} + +impl SubjectKeys { + fn from_vec(mut keys: Vec) -> Self { + if keys.len() == 1 { + Self::One(keys.pop().unwrap()) + } else { + Self::Many(keys.into_boxed_slice()) + } + } +} + +fn key_rank(key: &SelectorKey) -> u8 { + match key { + SelectorKey::Universal => 0, + SelectorKey::Local(_) => 1, + SelectorKey::Attribute(_) => 2, + SelectorKey::Class(_) => 3, + SelectorKey::Id(_) => 4, + SelectorKey::Root => 5, + } +} + +fn push_unique_key(keys: &mut Vec, key: SelectorKey) { + if !keys.contains(&key) { + keys.push(key); + } +} + +/// Candidate buckets for a selector's rightmost compound. A single ordinary +/// id/class/local selector keeps the allocation-free common path. A pure +/// `:is()`/`:where()` subject returns the deduplicated union of its arms, but +/// only when every arm has a concrete key; one universal/pseudo-only +/// arm makes the whole set universal because that arm can match an element +/// carrying none of the other keys. +/// +/// When an outer compound key exists, disjoint functional-pseudo keys replace +/// it only if every arm is strictly more selective. This is the same +/// conservative contract as Gecko's `SelectorMap::find_bucket`: for +/// `.control:is(button, #save)` keep `.control`, while +/// `.control:is(#save, #cancel)` may use the two id buckets. +fn subject_keys(sel: &parser::Selector) -> Vec { use parser::Component; let mut local: Option = None; + let mut attribute: Option = None; let mut class: Option = None; let mut id: Option = None; + let mut root = false; + let mut disjoint_sets: Vec> = Vec::new(); for comp in sel.iter_raw_match_order() { match comp { Component::Combinator(_) => break, Component::ID(v) => id = Some(v.0.clone()), + Component::Root => root = true, Component::Class(v) => class = Some(v.0.clone()), Component::LocalName(l) => local = Some(l.lower_name.0.to_string()), + Component::AttributeInNoNamespaceExists { + local_name_lower, .. + } => attribute = Some(local_name_lower.0.to_string()), + Component::AttributeInNoNamespace { local_name, .. } => { + attribute = Some(local_name.0.to_string()) + } + Component::AttributeOther(selector) => { + attribute = Some(selector.local_name_lower.0.to_string()) + } + Component::Is(list) | Component::Where(list) => { + let mut keys = Vec::new(); + let mut universal = false; + for alternative in list.slice() { + let alternative_keys = subject_keys(alternative); + if alternative_keys + .iter() + .any(|key| matches!(key, SelectorKey::Universal)) + { + universal = true; + break; + } + for key in alternative_keys { + push_unique_key(&mut keys, key); + } + } + if universal || keys.is_empty() { + keys.clear(); + keys.push(SelectorKey::Universal); + } + disjoint_sets.push(keys); + } _ => {} } } - if let Some(i) = id { + let direct = if root { + SelectorKey::Root + } else if let Some(i) = id { SelectorKey::Id(i) } else if let Some(c) = class { SelectorKey::Class(c) + } else if let Some(a) = attribute { + SelectorKey::Attribute(a) } else if let Some(l) = local { SelectorKey::Local(l) } else { SelectorKey::Universal + }; + + let direct_rank = key_rank(&direct); + let mut best: Option> = None; + for keys in disjoint_sets { + if keys.iter().any(|key| key_rank(key) <= direct_rank) { + continue; + } + let min_rank = keys.iter().map(key_rank).min().unwrap_or(0); + let replaces = best.as_ref().is_none_or(|current| { + let current_min = current.iter().map(key_rank).min().unwrap_or(0); + min_rank > current_min || (min_rank == current_min && keys.len() < current.len()) + }); + if replaces { + best = Some(keys); + } } + best.unwrap_or_else(|| vec![direct]) } #[cfg(test)] mod tests { use crate::tree_sink::parse_html; + use super::SelectorKey; + + fn candidate_keys(tree: &crate::tree::DomTree, selector: &str) -> Vec { + tree.compile_rule_selector(selector) + .unwrap_or_else(|| panic!("failed to compile {selector}")) + .candidate_keys() + .to_vec() + } + + fn element_has_selector_key( + tree: &crate::tree::DomTree, + node: crate::tree::NodeId, + key: &SelectorKey, + ) -> bool { + let node = tree.get_node(node).expect("matched node must exist"); + match key { + SelectorKey::Root => node + .parent + .and_then(|parent| tree.get_node(parent)) + .is_some_and(|parent| parent.is_document()), + SelectorKey::Universal => true, + SelectorKey::Id(id) => node.get_attribute("id") == Some(id.as_str()), + SelectorKey::Class(class) => node.get_attribute("class").is_some_and(|classes| { + classes.split_ascii_whitespace().any(|value| value == class) + }), + SelectorKey::Attribute(attribute) => node.get_attribute(attribute).is_some(), + SelectorKey::Local(local) => node + .as_element() + .is_some_and(|element| element.local.as_ref() == local), + } + } + + #[test] + fn selector_candidate_keys_expand_rightmost_is_and_where() { + let tree = parse_html("
"); + + assert_eq!( + candidate_keys(&tree, ":is(.IssueLabel, .Label):hover"), + vec![ + SelectorKey::Class("IssueLabel".into()), + SelectorKey::Class("Label".into()), + ] + ); + assert_eq!( + candidate_keys(&tree, ":where(button, input, select)"), + vec![ + SelectorKey::Local("button".into()), + SelectorKey::Local("input".into()), + SelectorKey::Local("select".into()), + ] + ); + + // A legacy single-bucket index stays correct until it explicitly opts + // into inserting all candidate keys. + let compiled = tree + .compile_rule_selector(":is(.IssueLabel, .Label):hover") + .unwrap(); + assert_eq!(compiled.key(), &SelectorKey::Universal); + } + + #[test] + fn selector_candidate_keys_deduplicate_equivalent_arms() { + let tree = parse_html("
"); + let compiled = tree + .compile_rule_selector(":is(.btn, .btn:hover, :where(.btn))") + .unwrap(); + + assert_eq!( + compiled.candidate_keys(), + &[SelectorKey::Class("btn".into())] + ); + assert_eq!(compiled.key(), &SelectorKey::Class("btn".into())); + } + + #[test] + fn selector_candidate_keys_index_attributes_and_fall_back_for_pseudos() { + let tree = parse_html("
"); + assert_eq!( + candidate_keys(&tree, ":is(.btn, [data-kind])"), + vec![ + SelectorKey::Class("btn".into()), + SelectorKey::Attribute("data-kind".into()), + ] + ); + assert_eq!( + candidate_keys(&tree, ":is(.btn, :first-child)"), + vec![SelectorKey::Universal] + ); + } + + #[test] + fn selector_candidate_keys_index_root_subjects_and_functional_arms() { + let tree = parse_html("
"); + assert_eq!(candidate_keys(&tree, ":root"), vec![SelectorKey::Root]); + assert_eq!( + candidate_keys(&tree, ":root[data-color-mode]"), + vec![SelectorKey::Root], + ); + assert_eq!( + candidate_keys(&tree, ":is(:root, .app)"), + vec![SelectorKey::Root, SelectorKey::Class("app".into())], + ); + } + + #[test] + fn selector_candidate_keys_only_replace_outer_key_when_more_selective() { + let tree = parse_html("
"); + assert_eq!( + candidate_keys(&tree, ".control:is(#save, #cancel)"), + vec![ + SelectorKey::Id("save".into()), + SelectorKey::Id("cancel".into()), + ] + ); + assert_eq!( + candidate_keys(&tree, ".control:is(button, #save)"), + vec![SelectorKey::Class("control".into())] + ); + } + + #[test] + fn selector_candidate_keys_use_subjects_of_complex_is_arms() { + let tree = parse_html("
"); + assert_eq!( + candidate_keys(&tree, ".scope :is(.a > .x, .b > .y)"), + vec![ + SelectorKey::Class("x".into()), + SelectorKey::Class("y".into()), + ] + ); + } + + #[test] + fn selector_candidate_keys_cover_every_actual_match() { + let tree = parse_html( + r#"
+
+ + +
+
+
"#, + ); + let selectors = [ + ":is(.alpha, .beta)", + ":where(button, input)", + ".control:is(#save, #cancel)", + ".control:is(button, #save)", + ":is(.alpha, [data-kind])", + ":is(:root, .control)", + ".scope :is(.a > .x, .b > .y)", + ]; + + for selector in selectors { + let keys = candidate_keys(&tree, selector); + let matches = tree.query_selector_all(selector).unwrap(); + assert!(!matches.is_empty(), "fixture must exercise {selector}"); + for matched in matches { + assert!( + keys.iter() + .any(|key| element_has_selector_key(&tree, matched, key)), + "{selector} matched an element outside all candidate buckets: {keys:?}" + ); + } + } + } + + #[test] + fn matcher_candidate_collection_deduplicates_and_reuses_indices() { + let tree = parse_html("
"); + let mut matcher = tree.matcher(); + + matcher.begin_candidate_collection(4); + assert!(matcher.mark_candidate(2)); + assert!(!matcher.mark_candidate(2)); + + matcher.begin_candidate_collection(4); + assert!(matcher.mark_candidate(2)); + assert!(!matcher.mark_candidate(2)); + + matcher.begin_candidate_collection(9); + assert!(matcher.mark_candidate(8)); + assert!(!matcher.mark_candidate(8)); + + matcher.candidate_generation = u32::MAX; + matcher.begin_candidate_collection(9); + assert_eq!(matcher.candidate_generation, 1); + assert!(matcher.mark_candidate(2)); + assert!(!matcher.mark_candidate(2)); + } + #[test] fn test_query_selector_tag() { let tree = parse_html("

Title

Text

"); @@ -964,8 +1364,7 @@ mod tests { #[test] fn test_query_selector_class() { - let tree = - parse_html(r#"
Content
Other
"#); + let tree = parse_html(r#"
Content
Other
"#); let result = tree.query_selector(".foo").unwrap(); assert!(result.is_some()); let node = tree.get_node(result.unwrap()).unwrap(); @@ -989,7 +1388,10 @@ mod tests { #[test] fn test_has_parses() { // Isolate parse from match: :has must parse, not error. - assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse"); + assert!( + super::parse_selector("a:has(p.bt)").is_ok(), + ":has failed to parse" + ); } #[test] @@ -1005,9 +1407,17 @@ mod tests { fn test_enabled_matches_form_control_without_disabled_attr() { let tree = parse_html(r#""#); let enabled = tree.query_selector_all("button:enabled").unwrap(); - assert_eq!(enabled.len(), 1, ":enabled should match only the non-disabled button"); + assert_eq!( + enabled.len(), + 1, + ":enabled should match only the non-disabled button" + ); let disabled = tree.query_selector_all("button:disabled").unwrap(); - assert_eq!(disabled.len(), 1, ":disabled should match only the disabled button"); + assert_eq!( + disabled.len(), + 1, + ":disabled should match only the disabled button" + ); } #[test] @@ -1021,33 +1431,23 @@ mod tests { #[test] fn test_checked_matches_checked_attribute() { - let tree = parse_html( - r#""#, - ); + let tree = parse_html(r#""#); assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1); } #[test] fn unfocused_snapshot_matches_focus_negation_selectors() { - let tree = parse_html( - r#"
Skip
"#, - ); + let tree = parse_html(r#"
Skip
"#); let hidden = tree - .query_selector_all( - ".visually-hidden-focusable:not(:focus):not(:focus-within)", - ) + .query_selector_all(".visually-hidden-focusable:not(:focus):not(:focus-within)") .unwrap(); assert_eq!(hidden.len(), 1); - assert_eq!( - tree.query_selector_all(":focus-visible").unwrap().len(), - 0 - ); + assert_eq!(tree.query_selector_all(":focus-visible").unwrap().len(), 0); } #[test] fn test_query_selector_descendant() { - let tree = - parse_html(r#"
Target
"#); + let tree = parse_html(r#"
Target
"#); let result = tree.query_selector("#outer span").unwrap(); assert!(result.is_some()); let node = tree.get_node(result.unwrap()).unwrap(); @@ -1072,9 +1472,8 @@ mod tests { #[test] fn test_query_selector_attribute() { - let tree = parse_html( - r#""#, - ); + let tree = + parse_html(r#""#); let result = tree.query_selector(r#"input[type="password"]"#).unwrap(); assert!(result.is_some()); let node = tree.get_node(result.unwrap()).unwrap(); @@ -1151,13 +1550,15 @@ mod tests { #[test] fn test_query_selector_from_returns_first_in_subtree_only() { - let tree = parse_html( - r#"

first

second

outside

"#, - ); + let tree = + parse_html(r#"

first

second

outside

"#); let s = tree.get_element_by_id("s").expect("section#s"); // Scoped to #s: skip the outside paragraph; return the first inside. - let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside"); + let first_in_s = tree + .query_selector_from(s, "p") + .unwrap() + .expect("a p inside"); assert_eq!(tree.text_content(first_in_s), "first"); } diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml index 61f4e8b..28e8e13 100644 --- a/crates/obscura-js/Cargo.toml +++ b/crates/obscura-js/Cargo.toml @@ -25,6 +25,10 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } url = { workspace = true } +# document.domain must reject public suffixes (including private suffixes such +# as github.io), not just compare dotted strings. `psl` embeds the Mozilla +# Public Suffix List and adds no runtime I/O. +psl = "2" tracing = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 11221dc..763b2d1 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -87,8 +87,6 @@ const _DOM_MUTATION_COMMANDS = new Set([ "set_attribute", "remove_attribute", "set_text_content", "set_inner_html", "set_inner_html_context", "set_fragment_html_executable", - "create_element", "create_text_node", "create_comment_node", - "create_document_fragment", "clone_node", ]); const _dom = (cmd, a1, a2) => { const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); @@ -622,11 +620,27 @@ globalThis.console = { let _tid = 0; const _clearedTimers = new Set(); const _intervals = new Set(); +const _nativeTimerIds = new Map(); +const __obscuraPendingTimeoutDeadlines = new Map(); const _scheduleAfter = (delay, fn) => { const d = Math.max(0, Number(delay) || 0); - if (d === 0) Promise.resolve().then(fn); - else Deno.core.ops.op_sleep(d).then(fn); + // HTML timers queue tasks even when their delay is zero. Treating a + // zero-delay timer as a Promise reaction turns recursive framework + // schedulers into an unbounded microtask checkpoint: timers and networking + // never regain control and V8 can burn seconds before navigation completes. + // deno_core's timer queue requires a Tokio reactor even to enqueue. Some + // low-level embedders intentionally do a synchronous geometry mutation and + // capture without pumping an event loop. Such a host cannot observe queued + // tasks, so leave them pending instead of aborting or incorrectly turning a + // task into a microtask. Normal browser and CDP execution always takes the + // task-queue path below. + if (!Deno.core.ops.op_async_runtime_available()) { + return undefined; + } + // The callback runs only when the embedder pumps the event loop, after the + // current microtask checkpoint. + return Deno.core.queueUserTimer(0, false, d, fn); }; // Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF @@ -652,14 +666,29 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => { const f = _coerceTimerFn(fn); if (f === null) return ++_tid; const id = ++_tid; - _scheduleAfter(delay, () => { + const normalizedDelay = Math.max(0, Number(delay) || 0); + const nativeId = _scheduleAfter(normalizedDelay, () => { + _nativeTimerIds.delete(id); + __obscuraPendingTimeoutDeadlines.delete(id); if (_clearedTimers.has(id)) return; try { f(...args); } catch(e) { console.error("Timer error:", e); } }); + if (nativeId !== undefined) { + _nativeTimerIds.set(id, nativeId); + __obscuraPendingTimeoutDeadlines.set(id, performance.now() + normalizedDelay); + } return id; }; -globalThis.clearTimeout = (id) => { _clearedTimers.add(id); }; +globalThis.clearTimeout = (id) => { + _clearedTimers.add(id); + __obscuraPendingTimeoutDeadlines.delete(id); + const nativeId = _nativeTimerIds.get(id); + if (nativeId !== undefined) { + Deno.core.cancelTimer(nativeId); + _nativeTimerIds.delete(id); + } +}; globalThis.setInterval = (fn, delay = 0, ...args) => { const f = _coerceTimerFn(fn); @@ -670,13 +699,18 @@ globalThis.setInterval = (fn, delay = 0, ...args) => { if (!_intervals.has(id)) return; try { f(...args); } catch(e) { console.error("Interval error:", e); } if (!_intervals.has(id)) return; - _scheduleAfter(delay, tick); + const nativeId = _scheduleAfter(delay, tick); + if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId); }; - _scheduleAfter(delay, tick); + const nativeId = _scheduleAfter(delay, tick); + if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId); return id; }; -globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); }; +globalThis.clearInterval = (id) => { + _intervals.delete(id); + globalThis.clearTimeout(id); +}; // Animation callbacks are a rendering-phase batch, not zero-delay // microtasks. In particular, a callback which queues itself must yield to @@ -739,20 +773,155 @@ globalThis.cancelAnimationFrame = (id) => { }; globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); +// MessagePort is a task-backed EventTarget, not a pair of callback slots. +// React currently uses `onmessage`, while Angular/Zone.js and worker-style +// schedulers commonly use addEventListener + start and inspect the prototype. +// Keep stopped-port messages queued, clone payloads synchronously, and deliver +// one message per task so every delivery gets its own microtask checkpoint. +const _messagePortConstructionKey = {}; +const _messagePortState = new WeakMap(); +function _messagePortStateFor(port) { + const state = _messagePortState.get(port); + if (!state) throw new TypeError("Illegal invocation"); + return state; +} +function _messagePortInstallEventHandler(port, type, callback) { + const state = _messagePortStateFor(port); + const slot = type === "message" ? "onmessage" : "onmessageerror"; + const wrapperSlot = type === "message" ? "messageHandlerWrapper" : "messageErrorHandlerWrapper"; + const oldCallback = state[slot]; + state[slot] = callback; + + // Event-handler IDL attributes participate in the same listener list as + // addEventListener. Install their stable wrapper when the slot first becomes + // non-null so mixed registrations run in registration order. Reassigning a + // live handler keeps its position; clearing and setting it again appends it. + if (callback && !oldCallback) { + const wrapper = (event) => { + const current = _messagePortState.get(port)?.[slot]; + if (!current) return; + if (typeof current === "function") current.call(port, event); + else current.handleEvent.call(current, event); + }; + state[wrapperSlot] = wrapper; + _eventTargetAdd(port, type, wrapper); + } else if (!callback && oldCallback) { + _eventTargetRemove(port, type, state[wrapperSlot]); + state[wrapperSlot] = null; + } +} +function _messagePortScheduleDelivery(port) { + const state = _messagePortStateFor(port); + if (state.closed || !state.messageQueueEnabled || state.messageDeliveryPending || !state.messageQueue.length) return; + state.messageDeliveryPending = true; + _scheduleAfter(0, () => { + const current = _messagePortState.get(port); + if (!current) return; + current.messageDeliveryPending = false; + if (current.closed || !current.messageQueueEnabled || !current.messageQueue.length) return; + const data = current.messageQueue.shift(); + const event = new MessageEvent("message", { + data, + origin: "", + lastEventId: "", + source: null, + ports: [], + }); + _eventTargetDispatch(port, event); + _messagePortScheduleDelivery(port); + }); +} +class MessagePort { + constructor(key) { + if (key !== _messagePortConstructionKey) throw new TypeError("Illegal constructor"); + _messagePortState.set(this, { + entangled: null, + messageQueue: [], + messageQueueEnabled: false, + messageDeliveryPending: false, + closed: false, + onmessage: null, + onmessageerror: null, + messageHandlerWrapper: null, + messageErrorHandlerWrapper: null, + }); + } + postMessage(message, options) { + // Structured serialization happens before inspecting the entanglement. + // This preserves the browser-observable DataCloneError on closed ports and + // prevents mutations after postMessage from changing the delivered value. + let cloned; + try { + cloned = globalThis.structuredClone(message, options); + } catch (error) { + throw error; + } + const state = _messagePortStateFor(this); + const target = state.entangled; + const targetState = target && _messagePortState.get(target); + if (state.closed || !targetState || targetState.closed) return; + targetState.messageQueue.push(cloned); + _messagePortScheduleDelivery(target); + } + start() { + const state = _messagePortStateFor(this); + if (state.messageQueueEnabled || state.closed) return; + state.messageQueueEnabled = true; + _messagePortScheduleDelivery(this); + } + close() { + const state = _messagePortStateFor(this); + if (state.closed) return; + state.closed = true; + state.messageQueue.length = 0; + state.messageQueueEnabled = false; + const peer = state.entangled; + state.entangled = null; + const peerState = peer && _messagePortState.get(peer); + if (peerState?.entangled === this) peerState.entangled = null; + // A previously scheduled task cannot be removed from the shared task + // source, but it observes `closed` and therefore cannot dispatch. + } + addEventListener(type, callback, options) { + _eventTargetAdd(this, type, callback, options); + } + removeEventListener(type, callback, options) { + _eventTargetRemove(this, type, callback, options); + } + dispatchEvent(event) { + _messagePortStateFor(this); + return _eventTargetDispatch(this, event); + } + get onmessage() { return _messagePortStateFor(this).onmessage; } + set onmessage(callback) { + callback = typeof callback === "function" + || (callback && typeof callback.handleEvent === "function") + ? callback : null; + _messagePortInstallEventHandler(this, "message", callback); + // Setting the event-handler IDL attribute implicitly starts the port, + // including when the assigned value is null. + this.start(); + } + get onmessageerror() { return _messagePortStateFor(this).onmessageerror; } + set onmessageerror(callback) { + callback = typeof callback === "function" + || (callback && typeof callback.handleEvent === "function") + ? callback : null; + _messagePortInstallEventHandler(this, "messageerror", callback); + } + get [Symbol.toStringTag]() { return "MessagePort"; } +} + class MessageChannel { constructor() { - this.port1 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; - this.port2 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; - this.port1.postMessage = (data) => { - Promise.resolve().then(() => { if (this.port2.onmessage) this.port2.onmessage({ data }); }); - }; - this.port2.postMessage = (data) => { - Promise.resolve().then(() => { if (this.port1.onmessage) this.port1.onmessage({ data }); }); - }; + this.port1 = new MessagePort(_messagePortConstructionKey); + this.port2 = new MessagePort(_messagePortConstructionKey); + _messagePortStateFor(this.port1).entangled = this.port2; + _messagePortStateFor(this.port2).entangled = this.port1; } } globalThis.MessageChannel = MessageChannel; -globalThis.MessagePort = class MessagePort { constructor(){} postMessage(){} close(){} addEventListener(){} removeEventListener(){} }; +globalThis.MessagePort = MessagePort; const _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); @@ -3529,6 +3698,26 @@ function _makeXPathResult(type, nodes) { }; } +// `document.domain` exposes the document's effective host. Keep the relaxed +// value on the live Document object so a navigation (which installs a new +// Document in __obscura_init) naturally restores the URL host. Detached +// documents inherit the incumbent realm's principal for reads, which is why a +// `new Document().domain` read reflects the live document rather than its own +// about:blank URL. +function _documentUrlHost() { + try { return new URL(_domParse("document_url") || "about:blank").hostname; } + catch (_) { return ""; } +} +function _incumbentDocumentDomain() { + const live = globalThis.document; + return live && typeof live._effectiveDomain === "string" + ? live._effectiveDomain + : _documentUrlHost(); +} +function _throwDocumentDomainSecurityError() { + throw new DOMException("Failed to set the 'domain' property on 'Document'", "SecurityError"); +} + class Document extends Node { get documentElement() { return _wrapEl(+_dom("document_element")); } get children() { @@ -3569,6 +3758,28 @@ class Document extends Node { } get URL() { return _domParse("document_url") ?? ""; } get documentURI() { return this.URL; } + get domain() { + return this === globalThis.document + ? (typeof this._effectiveDomain === "string" ? this._effectiveDomain : _documentUrlHost()) + : _incumbentDocumentDomain(); + } + set domain(value) { + // Web IDL performs DOMString conversion before the setter algorithm checks + // whether the Document has a browsing context. + const input = String(value); + if (this !== globalThis.document) _throwDocumentDomainSecurityError(); + const current = this.domain; + if (!current) _throwDocumentDomainSecurityError(); + const candidate = Deno.core.ops.op_document_domain_candidate(current, input); + if (!candidate) _throwDocumentDomainSecurityError(); + // This runtime currently has one top-level browsing context and no + // principal-backed same-origin-domain comparison. Persisting the + // validated effective domain supplies the standards-shaped API without + // weakening iframe/fetch/storage origin checks; those must be wired to a + // future browsing-context principal model before domain relaxation can + // grant cross-document access. + this._effectiveDomain = candidate; + } get referrer() { return _domParse("document_referrer") ?? ""; } get location() { return globalThis.location; } set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); } @@ -6054,28 +6265,36 @@ globalThis.matchMedia = _markNative(function matchMedia(q) { dispatchEvent(){return true;} }; }); +// getComputedStyle() returns a fresh declaration object, but those objects all +// observe the same computed style for an element until the document mutates. +// Share the immutable native snapshot behind them. Frameworks routinely call +// getComputedStyle() repeatedly on the same few roots; rebuilding and parsing +// several hundred properties for every wrapper dominated real-page startup. +const _computedStyleSnapshotCache = new WeakMap(); globalThis.getComputedStyle = (el) => { if (!el) el = document.body || {}; const style = el?.style || el?._style || new CSSStyleDeclaration(); // Render builds expose one immutable snapshot from the retained final - // cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a - // dozen properties must not trigger a dozen layout/native crossings. - let rendered = null; - let renderedEpoch = -1; - let computedNames = []; + // cascade/layout. The native snapshot is shared per element and epoch while + // each call still returns a distinct, live CSSStyleDeclaration proxy. + const cacheable = (typeof el === 'object' && el !== null) || typeof el === 'function'; + let snapshot = cacheable ? _computedStyleSnapshotCache.get(el) : null; + if (!snapshot) { + snapshot = { rendered: null, epoch: -1, names: [] }; + if (cacheable) _computedStyleSnapshotCache.set(el, snapshot); + } const refreshRendered = () => { - if (renderedEpoch === _domMutationEpoch) return; - renderedEpoch = _domMutationEpoch; - rendered = null; + if (snapshot.epoch === _domMutationEpoch) return; + snapshot.epoch = _domMutationEpoch; + snapshot.rendered = null; if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { try { const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); - rendered = raw ? JSON.parse(raw) : null; + snapshot.rendered = raw ? JSON.parse(raw) : null; } catch (e) {} } - computedNames = rendered ? Object.keys(rendered) : []; + snapshot.names = snapshot.rendered ? Object.keys(snapshot.rendered) : []; }; - refreshRendered(); // React virtualization libraries (react-window, tanstack-virtual, // react-virtuoso) all compute container dimensions via getComputedStyle. // The defaults table previously returned `auto` for width/height and @@ -6139,8 +6358,8 @@ globalThis.getComputedStyle = (el) => { // (`-webkit-line-clamp`). Normalize the prefix once for every WebKit // property instead of adding per-property aliases to the native snapshot. if (kebab.startsWith('webkit-')) kebab = '-' + kebab; - if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) - return rendered[kebab]; + if (snapshot.rendered && Object.prototype.hasOwnProperty.call(snapshot.rendered, kebab)) + return snapshot.rendered[kebab]; // Non-render builds and properties outside the renderer snapshot retain // the lightweight inline CSSOM behavior. const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : ''; @@ -6160,11 +6379,11 @@ globalThis.getComputedStyle = (el) => { if (prop === 'getPropertyPriority') return () => ''; if (prop === 'item') return (i) => { refreshRendered(); - return computedNames[i | 0] || ''; + return snapshot.names[i | 0] || ''; }; if (prop === 'length') { refreshRendered(); - return computedNames.length; + return snapshot.names.length; } if (prop === 'cssText') return ''; if (prop === 'parentRule') return null; @@ -6530,6 +6749,23 @@ globalThis.NodeFilter = { // target boxes through getBoundingClientRect. Element scroll-container roots // remain intentionally unsupported until their offsets participate in layout. globalThis.__intersectionObservers = []; +let _intersectionRenderCheckpointPending = false; +function _scheduleIntersectionRenderCheckpoint() { + if (_intersectionRenderCheckpointPending) return; + _intersectionRenderCheckpointPending = true; + // Intersection observation is part of the browser's rendering update, not + // a synchronous side effect of observe() or a DOM mutation. One queued task + // coalesces every observer/target and therefore performs at most one layout + // flush for the checkpoint. + _scheduleAfter(0, () => { + _intersectionRenderCheckpointPending = false; + for (const observer of globalThis.__intersectionObservers) { + if (observer._connected && observer._targets.size) { + observer._check([...observer._targets], false); + } + } + }); +} function _ioRect(x, y, width, height) { return { x, y, width, height, @@ -6664,7 +6900,7 @@ globalThis.IntersectionObserver = class IntersectionObserver { } this._targets.add(el); this._previous.delete(el); - this._check([el], true); + _scheduleIntersectionRenderCheckpoint(); } unobserve(el) { this._targets.delete(el); @@ -6686,18 +6922,8 @@ globalThis.IntersectionObserver = class IntersectionObserver { get thresholds() { return this._thresholds.slice(); } }; (function() { - let pending = false; const recompute = () => { - if (pending) return; - pending = true; - Promise.resolve().then(() => { - pending = false; - for (const observer of globalThis.__intersectionObservers) { - if (observer._connected && observer._targets.size) { - observer._check([...observer._targets], false); - } - } - }); + _scheduleIntersectionRenderCheckpoint(); }; globalThis.__obscura_recompute_intersections = recompute; globalThis.addEventListener("resize", recompute); @@ -6920,7 +7146,16 @@ globalThis.PopStateEvent = class extends Event { } }; globalThis.HashChangeEvent = class extends Event {}; -globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } }; +globalThis.MessageEvent = class extends Event { + constructor(t,o={}) { + super(t,o); + this.data = Object.prototype.hasOwnProperty.call(o, "data") ? o.data : null; + this.origin = o.origin == null ? "" : String(o.origin); + this.lastEventId = o.lastEventId == null ? "" : String(o.lastEventId); + this.source = o.source == null ? null : o.source; + this.ports = Array.isArray(o.ports) ? o.ports.slice() : []; + } +}; globalThis.ProgressEvent = class ProgressEvent extends Event { constructor(type, init) { super(type, init || {}); @@ -7315,6 +7550,8 @@ globalThis.DOMParser = class DOMParser { // URL about:blank, are already fully parsed, and carry no stylesheets. get URL() { return "about:blank"; }, get documentURI() { return "about:blank"; }, + get domain() { return _incumbentDocumentDomain(); }, + set domain(value) { String(value); _throwDocumentDomainSecurityError(); }, get referrer() { return ""; }, get baseURI() { return "about:blank"; }, get compatMode() { return "CSS1Compat"; }, @@ -10813,14 +11050,127 @@ if (typeof WebSocket === 'undefined') { } if (typeof BroadcastChannel === 'undefined') { + // BroadcastChannel is used by authentication/session coordinators and by + // modern framework dev/runtime clients. Keep the registry realm-local: one + // Obscura page is one origin-bound browsing context today, so every channel + // in this registry has the same storage key and origin by construction. + const channelsByName = new Map(); + const channelState = new WeakMap(); + const stateFor = (channel) => { + const state = channelState.get(channel); + if (!state) throw new TypeError('Illegal invocation'); + return state; + }; + const installHandler = (channel, type, callback) => { + const state = stateFor(channel); + const slot = type === 'message' ? 'onmessage' : 'onmessageerror'; + const wrapperSlot = type === 'message' ? 'messageWrapper' : 'messageErrorWrapper'; + const oldCallback = state[slot]; + state[slot] = callback; + if (callback && !oldCallback) { + const wrapper = (event) => { + const current = channelState.get(channel)?.[slot]; + if (!current) return; + if (typeof current === 'function') current.call(channel, event); + else current.handleEvent.call(current, event); + }; + state[wrapperSlot] = wrapper; + _eventTargetAdd(channel, type, wrapper); + } else if (!callback && oldCallback) { + _eventTargetRemove(channel, type, state[wrapperSlot]); + state[wrapperSlot] = null; + } + }; + globalThis.BroadcastChannel = class BroadcastChannel { constructor(name) { - this.name = name; this.onmessage = null; this.onmessageerror = null; - _makeListenerBox(this); + if (arguments.length < 1) { + throw new TypeError("Failed to construct 'BroadcastChannel': 1 argument required."); + } + const normalizedName = String(name); + const state = { + name: normalizedName, + closed: false, + onmessage: null, + onmessageerror: null, + messageWrapper: null, + messageErrorWrapper: null, + }; + channelState.set(this, state); + let channels = channelsByName.get(normalizedName); + if (!channels) channelsByName.set(normalizedName, channels = new Set()); + channels.add(this); } - postMessage(msg) {} - close() {} + get name() { return stateFor(this).name; } + get onmessage() { return stateFor(this).onmessage; } + set onmessage(callback) { + callback = typeof callback === 'function' + || (callback && typeof callback.handleEvent === 'function') + ? callback : null; + installHandler(this, 'message', callback); + } + get onmessageerror() { return stateFor(this).onmessageerror; } + set onmessageerror(callback) { + callback = typeof callback === 'function' + || (callback && typeof callback.handleEvent === 'function') + ? callback : null; + installHandler(this, 'messageerror', callback); + } + addEventListener(type, callback, options) { + stateFor(this); + _eventTargetAdd(this, type, callback, options); + } + removeEventListener(type, callback, options) { + stateFor(this); + _eventTargetRemove(this, type, callback, options); + } + dispatchEvent(event) { + stateFor(this); + return _eventTargetDispatch(this, event); + } + postMessage(message) { + const state = stateFor(this); + if (state.closed) { + throw new DOMException("BroadcastChannel is closed.", "InvalidStateError"); + } + + // Serialization is synchronous and precedes recipient selection. This + // preserves DataCloneError even when no peer is listening and freezes + // the posted graph before the caller can mutate it. + const snapshot = globalThis.structuredClone(message); + const recipients = Array.from(channelsByName.get(state.name) || []) + .filter((channel) => channel !== this && !channelState.get(channel)?.closed); + const origin = globalThis.location?.origin || ''; + for (const recipient of recipients) { + // Each destination gets an independent deserialization, not a shared + // JS object. Clone now so all serialization remains part of postMessage. + const data = globalThis.structuredClone(snapshot); + _scheduleAfter(0, () => { + const recipientState = channelState.get(recipient); + if (!recipientState || recipientState.closed) return; + _eventTargetDispatch(recipient, new MessageEvent('message', { + data, + origin, + source: null, + ports: [], + })); + }); + } + } + close() { + const state = stateFor(this); + if (state.closed) return; + state.closed = true; + const channels = channelsByName.get(state.name); + if (!channels) return; + channels.delete(this); + if (!channels.size) channelsByName.delete(state.name); + } + get [Symbol.toStringTag]() { return 'BroadcastChannel'; } }; + // EventTarget is currently Node-backed in this runtime; link the prototype + // without invoking Node's DOM-node constructor or exposing a fake `_nid`. + Object.setPrototypeOf(globalThis.BroadcastChannel.prototype, globalThis.EventTarget.prototype); } if (typeof MediaQueryList === 'undefined') { diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index 9836525..f80e9f4 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -1,10 +1,10 @@ pub mod cdp_watchdog; mod import_map; -pub mod module_loader; -pub mod runtime; -pub mod ops; -pub mod v8_flags; pub mod markdown; +pub mod module_loader; +pub mod ops; +pub mod runtime; +pub mod v8_flags; pub use markdown::HTML_TO_MARKDOWN_JS; pub use v8_flags::set_v8_flags; @@ -13,6 +13,6 @@ pub use v8_flags::set_v8_flags; // render feature (which enables obscura-render/paint) is compiled in. #[cfg(feature = "render")] pub use obscura_render::{ - screenshot_png, screenshot_png_scrolled, CaptureError, CaptureRegion, + screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, }; diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 1f0908a..7bdb11f 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -3,19 +3,25 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::sync::Arc; -use deno_core::op2; -use deno_core::OpState; -use deno_core::Extension; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use deno_core::op2; +use deno_core::Extension; +use deno_core::OpState; use obscura_dom::{DomTree, NodeData, NodeId}; -use obscura_net::{CallbackRegistry, CookieJar, ObscuraHttpClient, RequestInfo, ResourceType, Response}; #[cfg(feature = "stealth")] use obscura_net::StealthHttpClient; +use obscura_net::{ + CallbackRegistry, CookieJar, ObscuraHttpClient, RequestInfo, ResourceType, Response, +}; use tokio::sync::Mutex; use crate::import_map::ImportMap; -pub type InterceptCallback = Arc Option<(u16, String, String)> + Send + Sync>>>>; +pub type InterceptCallback = Arc< + Mutex< + Option Option<(u16, String, String)> + Send + Sync>>, + >, +>; #[derive(Debug)] pub enum InterceptResolution { @@ -30,7 +36,9 @@ pub enum InterceptResolution { headers: HashMap, body: String, }, - Fail { reason: String }, + Fail { + reason: String, + }, } pub struct InterceptedRequest { @@ -109,6 +117,14 @@ pub struct ObscuraState { // drained by the Page into its network_events so the CDP layer emits // Network.requestWillBeSent / responseReceived for them (issue #406). pub js_network_events: Vec, + /// Requests initiated by this runtime only. Browser contexts share their + /// transport client across pages, so the client's aggregate counter cannot + /// be used as a page-readiness signal. + pub page_in_flight: Arc, + /// Monotonic generation for observable changes to the connected document. + /// The browser settle policy samples this to distinguish useful deferred + /// rendering work from unrelated long-lived timers. + pub activity_generation: u64, /// Final image/font-aware layout shared by CSSOM geometry and screenshots. /// DOM/style/viewport changes clear this value but retain resource bytes. #[cfg(feature = "render")] @@ -117,6 +133,11 @@ pub struct ObscuraState { /// relayout of the same document reuses it without refetching. #[cfg(feature = "render")] pub render_resources: obscura_render::RenderResourceCache, + /// One exact-key compiled author stylesheet for this document. Connected + /// mutations still discard `prepared_render`; the next prepare reuses only + /// parsing/indexing when ordered CSS source and viewport remain identical. + #[cfg(feature = "render")] + pub stylesheet_cache: obscura_render::StylesheetCache, /// Script-created faces in this document's `FontFaceSet`. This is separate /// from the DOM so the bridge does not manufacture a selector-visible /// ` +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(40.0) + ); + { + let state = rt.state.borrow(); + assert_eq!(state.stylesheet_cache.miss_count(), 1); + assert_eq!(state.stylesheet_cache.hit_count(), 0); + assert!(state.stylesheet_cache.retained_source_bytes() > 0); + } + + // The compiled selector index is reusable, but matching and cascade + // must observe the new class on the live connected element. + rt.evaluate("document.getElementById('box').className = 'b'") + .unwrap(); + assert!(rt.state.borrow().prepared_render.is_none()); + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(80.0) + ); + { + let state = rt.state.borrow(); + assert_eq!(state.stylesheet_cache.miss_count(), 1); + assert_eq!(state.stylesheet_cache.hit_count(), 1); + } + + // Style text is part of the exact key and cannot reuse stale rules. + rt.evaluate( + r#"document.getElementById('sheet').textContent = + '.b{width:120px;height:20px}@media(min-width:250px){.b{width:160px}}'"#, + ) + .unwrap(); + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(120.0) + ); + { + let state = rt.state.borrow(); + assert_eq!(state.stylesheet_cache.miss_count(), 2); + assert_eq!(state.stylesheet_cache.hit_count(), 1); + } + + // Media-query filtering is viewport-dependent, so an exact source hit + // at a different viewport must still reparse and reindex. + rt.set_viewport(300.0, 100.0); + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(160.0) + ); + let state = rt.state.borrow(); + assert_eq!(state.stylesheet_cache.miss_count(), 3); + assert_eq!(state.stylesheet_cache.hit_count(), 1); + } + #[cfg(feature = "render")] #[test] fn root_overflow_clip_preserves_cssom_scroll_range() { @@ -5222,7 +6291,10 @@ mod tests { .unwrap(); let values = no_op.value.unwrap(); let values = values.as_array().expect("array"); - assert_eq!(&values[0..2], &serde_json::json!([0, 0]).as_array().unwrap()[..]); + assert_eq!( + &values[0..2], + &serde_json::json!([0, 0]).as_array().unwrap()[..] + ); assert_eq!(values[2], values[3]); } @@ -5270,11 +6342,48 @@ mod tests { .unwrap(); assert_eq!( result.value.unwrap(), - serde_json::json!([ - [false, 0, 150, 0], - [true, 0.5, 50, 50], - [false, 0, -110, 0], - ]) + serde_json::json!([[false, 0, 150, 0], [true, 0.5, 50, 50], [false, 0, -110, 0],]) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn intersection_observer_initial_geometry_waits_for_one_render_checkpoint() { + let mut rt = setup_runtime( + r#"
"#, + ); + rt.execute_script( + "intersection-render-checkpoint", + r#" + globalThis.__ioOrder = ["sync"]; + globalThis.__ioReads = 0; + const first = document.getElementById("first"); + const second = document.getElementById("second"); + for (const element of [first, second]) { + const nativeRect = element.getBoundingClientRect.bind(element); + element.getBoundingClientRect = () => { + __ioReads++; + return nativeRect(); + }; + } + const observer = new IntersectionObserver( + () => __ioOrder.push("observer") + ); + observer.observe(first); + observer.observe(second); + Promise.resolve().then(() => __ioOrder.push("microtask")); + __ioOrder.push("after-observe-" + __ioReads); + "#, + ) + .unwrap(); + + assert_eq!( + rt.evaluate("[__ioOrder, __ioReads]").unwrap(), + serde_json::json!([["sync", "after-observe-0", "microtask"], 0]) + ); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[__ioOrder, __ioReads]").unwrap(), + serde_json::json!([["sync", "after-observe-0", "microtask", "observer"], 2]) ); } @@ -5440,10 +6549,7 @@ mod tests { .unwrap(); assert_eq!( result.value.unwrap(), - serde_json::json!([ - [["first"], ["second"]], - 0, - ]) + serde_json::json!([[["first"], ["second"]], 0,]) ); } @@ -5477,10 +6583,8 @@ mod tests { .unwrap(); rt.run_event_loop_bounded(10).await.unwrap(); - rt.evaluate( - r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#, - ) - .unwrap(); + rt.evaluate(r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#) + .unwrap(); rt.run_event_loop_bounded(10).await.unwrap(); assert_eq!( rt.evaluate("__ioRecords").unwrap(), @@ -5610,9 +6714,7 @@ mod tests { /// firstChild()/lastChild() must descend into them. FILTER_REJECT must not. #[test] fn tree_walker_child_movers_descend_on_skip_but_not_on_reject() { - let mut rt = setup_runtime( - r#"
"#, - ); + let mut rt = setup_runtime(r#"
"#); let result = rt .evaluate( r#" @@ -5805,7 +6907,9 @@ mod tests { #[test] fn test_inner_html() { let mut rt = setup_runtime(r#"

Hello

"#); - let html = rt.evaluate("document.getElementById('x').innerHTML").unwrap(); + let html = rt + .evaluate("document.getElementById('x').innerHTML") + .unwrap(); assert!(html.as_str().unwrap().contains("

")); } @@ -5849,9 +6953,7 @@ mod tests { // 60k four-character groups decode to 180k bytes, comfortably above // V8's maximum argument count for a single fromCharCode(...bytes). let encoded = "QUFB".repeat(60_000); - let result = rt - .evaluate(&format!("atob('{}').length", encoded)) - .unwrap(); + let result = rt.evaluate(&format!("atob('{}').length", encoded)).unwrap(); assert_eq!(result.as_f64().unwrap() as usize, 180_000); } @@ -5877,18 +6979,14 @@ mod tests { .unwrap(); assert_eq!( result, - serde_json::json!([ - "function", - "home", - "docs", - "http://example.com/docs" - ]) + serde_json::json!(["function", "home", "docs", "http://example.com/docs"]) ); } #[test] fn adopted_stylesheets_materialize_into_the_document() { - let mut rt = setup_runtime("

"); + let mut rt = + setup_runtime("
"); let result = rt .evaluate( r#" @@ -6014,17 +7112,18 @@ mod tests { /// crashed; that cascade should never happen. #[test] fn script_typeerror_does_not_poison_subsequent_execution() { - let mut rt = setup_runtime( - "

BODY_TEXT

", - ); + let mut rt = setup_runtime("

BODY_TEXT

"); // 1. First script throws the same flavor of error offside.js produced // (`Cannot read properties of undefined (reading 'classList')`). let err = rt .execute_script("buggy", "var x; x.classList.add('y');") .unwrap_err(); - assert!(err.contains("classList") || err.contains("undefined"), - "expected classList/undefined error, got: {}", err); + assert!( + err.contains("classList") || err.contains("undefined"), + "expected classList/undefined error, got: {}", + err + ); // 2. The runtime must still be usable: a follow-up script runs. rt.execute_script("ok", "globalThis.__after_error = 'still alive';") @@ -6046,12 +7145,21 @@ mod tests { #[test] fn thrown_error_in_one_script_does_not_stop_later_scripts() { let mut rt = setup_runtime(""); - rt.execute_script("s1", "globalThis.__ran1 = true;").unwrap(); + rt.execute_script("s1", "globalThis.__ran1 = true;") + .unwrap(); let err = rt - .execute_script("s2", "throw new Error('only one instance of babel-polyfill is allowed');") + .execute_script( + "s2", + "throw new Error('only one instance of babel-polyfill is allowed');", + ) .unwrap_err(); - assert!(err.contains("babel-polyfill"), "expected the thrown message, got: {}", err); - rt.execute_script("s3", "globalThis.__ran3 = true;").unwrap(); + assert!( + err.contains("babel-polyfill"), + "expected the thrown message, got: {}", + err + ); + rt.execute_script("s3", "globalThis.__ran3 = true;") + .unwrap(); let ran = rt .evaluate("JSON.stringify([globalThis.__ran1 === true, globalThis.__ran3 === true])") .unwrap(); @@ -6121,8 +7229,7 @@ mod tests { })()"#, ) .unwrap(); - let value: serde_json::Value = - serde_json::from_str(result.as_str().unwrap()).unwrap(); + let value: serde_json::Value = serde_json::from_str(result.as_str().unwrap()).unwrap(); assert_eq!(value["before"], serde_json::json!(["0px", "red", 2])); assert_eq!(value["removed"], "0px"); assert_eq!(value["after"], "color: red;"); @@ -6154,17 +7261,14 @@ mod tests { })()"#, ) .unwrap(); - let value: serde_json::Value = - serde_json::from_str(result.as_str().unwrap()).unwrap(); + let value: serde_json::Value = serde_json::from_str(result.as_str().unwrap()).unwrap(); assert_eq!(value["labels"], serde_json::json!(["Greek", "English"])); assert_eq!(value["selectedIndex"], 1); assert_eq!(value["value"], "en"); - assert!( - value["html"] - .as_str() - .unwrap() - .contains(r#""#) - ); + assert!(value["html"] + .as_str() + .unwrap() + .contains(r#""#)); } /// Regression for #105: `element.querySelector` and `querySelectorAll` @@ -6185,7 +7289,9 @@ mod tests { assert_eq!(count_in_a.as_f64().unwrap() as i64, 1); // Document-scoped query still sees both. - let count_doc = rt.evaluate("document.querySelectorAll('.x').length").unwrap(); + let count_doc = rt + .evaluate("document.querySelectorAll('.x').length") + .unwrap(); assert_eq!(count_doc.as_f64().unwrap() as i64, 2); } @@ -6204,12 +7310,29 @@ mod tests { /// `document.forms` and crashes when it's empty for pages that have forms. #[test] fn document_forms_images_links_are_live() { - let mut rt = setup_runtime( - r#"
lno-href"#, + let mut rt = + setup_runtime(r#"
lno-href"#); + assert_eq!( + rt.evaluate("document.forms.length") + .unwrap() + .as_f64() + .unwrap() as i64, + 2 + ); + assert_eq!( + rt.evaluate("document.images.length") + .unwrap() + .as_f64() + .unwrap() as i64, + 1 + ); + assert_eq!( + rt.evaluate("document.links.length") + .unwrap() + .as_f64() + .unwrap() as i64, + 1 ); - assert_eq!(rt.evaluate("document.forms.length").unwrap().as_f64().unwrap() as i64, 2); - assert_eq!(rt.evaluate("document.images.length").unwrap().as_f64().unwrap() as i64, 1); - assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1); } #[cfg(feature = "render")] @@ -6502,12 +7625,7 @@ mod tests { })()"#, ) .unwrap(), - serde_json::json!([ - true, - 2, - 3, - "http://example.com/page/cached.png" - ]) + serde_json::json!([true, 2, 3, "http://example.com/page/cached.png"]) ); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); } @@ -6526,17 +7644,14 @@ mod tests { .replace(char::is_whitespace, ""), ) .unwrap(); - let mut rt = parser_image_runtime( - r#""#, - move |url: &str| { - seen.lock().unwrap().push(url.to_string()); - if url.ends_with("/new.png") { - Some(second.clone()) - } else { - Some(first.clone()) - } - }, - ); + let mut rt = parser_image_runtime(r#""#, move |url: &str| { + seen.lock().unwrap().push(url.to_string()); + if url.ends_with("/new.png") { + Some(second.clone()) + } else { + Some(first.clone()) + } + }); rt.execute_script( "replace-image-source", r#" @@ -6620,7 +7735,10 @@ mod tests { // selection. The cache-only complete getter must report pending but // must not perform the load itself. rt.set_viewport(600.0, 600.0); - assert_eq!(rt.evaluate("pictureImage.complete").unwrap(), serde_json::json!(false)); + assert_eq!( + rt.evaluate("pictureImage.complete").unwrap(), + serde_json::json!(false) + ); assert_eq!(requests.lock().unwrap().len(), 1); rt.run_event_loop_bounded(100).await.unwrap(); assert_eq!( @@ -6657,7 +7775,8 @@ mod tests { ); rt.run_event_loop_bounded(100).await.unwrap(); assert_eq!( - rt.evaluate("[pictureImage.currentSrc, __pictureLoads]").unwrap(), + rt.evaluate("[pictureImage.currentSrc, __pictureLoads]") + .unwrap(), serde_json::json!([ "http://example.com/page/wide.png", [ @@ -6703,7 +7822,8 @@ mod tests { .unwrap(); rt.run_event_loop_bounded(100).await.unwrap(); assert_eq!( - rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]").unwrap(), + rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]") + .unwrap(), serde_json::json!([ "http://example.com/page/small.png", ["http://example.com/page/small.png"] @@ -6711,10 +7831,14 @@ mod tests { ); change_srcset_image_sizes(&mut rt); - assert_eq!(rt.evaluate("srcsetImage.complete").unwrap(), serde_json::json!(false)); + assert_eq!( + rt.evaluate("srcsetImage.complete").unwrap(), + serde_json::json!(false) + ); rt.run_event_loop_bounded(100).await.unwrap(); assert_eq!( - rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]").unwrap(), + rt.evaluate("[srcsetImage.currentSrc, __srcsetLoads]") + .unwrap(), serde_json::json!([ "http://example.com/page/large.png", [ @@ -6769,9 +7893,13 @@ mod tests { "#, ) .unwrap(); - let first_id = rt.evaluate("document.getElementById('c').firstChild.id").unwrap(); + let first_id = rt + .evaluate("document.getElementById('c').firstChild.id") + .unwrap(); assert_eq!(first_id, serde_json::json!("first")); - let count = rt.evaluate("document.getElementById('c').childNodes.length").unwrap(); + let count = rt + .evaluate("document.getElementById('c').childNodes.length") + .unwrap(); assert_eq!(count.as_f64().unwrap() as i64, 2); } @@ -6799,7 +7927,8 @@ mod tests { /// `insertBefore` actually inserts. #[test] fn insert_before_inserts_node_at_correct_position() { - let mut rt = setup_runtime(r#"
bc
"#); + let mut rt = + setup_runtime(r#"
bc
"#); let order = rt .evaluate( r#" @@ -6817,7 +7946,8 @@ mod tests { #[test] fn test_console_log() { let mut rt = setup_runtime(""); - rt.execute_script("test", "console.log('Hello from V8!')").unwrap(); + rt.execute_script("test", "console.log('Hello from V8!')") + .unwrap(); } #[test] @@ -6830,43 +7960,60 @@ mod tests { #[test] fn test_button_click_dispatches_listener() { let mut rt = setup_runtime(r#""#); - let result = rt.evaluate(r#" + let result = rt + .evaluate( + r#" const button = document.getElementById('go'); button.addEventListener('click', () => { button.dataset.clicked = 'yes'; }); button.click(); return button.dataset.clicked; - "#).unwrap(); + "#, + ) + .unwrap(); assert_eq!(result, serde_json::json!("yes")); } #[test] fn test_dispatch_mouse_event_runs_listener() { let mut rt = setup_runtime(r#""#); - let result = rt.evaluate(r#" + let result = rt + .evaluate( + r#" const button = document.getElementById('go'); let count = 0; button.addEventListener('click', () => { count += 1; }); button.dispatchEvent(new MouseEvent('click', { bubbles: true })); return count; - "#).unwrap(); + "#, + ) + .unwrap(); assert_eq!(result.as_f64().unwrap() as i64, 1); } #[test] fn test_location_href_assignment_updates_navigation_state() { let mut rt = setup_runtime(""); - let href = rt.evaluate("const next = '/next'; location.href = next; return location.href;").unwrap(); + let href = rt + .evaluate("const next = '/next'; location.href = next; return location.href;") + .unwrap(); assert_eq!(href, serde_json::json!("http://example.com/next")); assert_eq!( rt.take_pending_navigation(), - Some(("http://example.com/next".to_string(), "GET".to_string(), "".to_string())) + Some(( + "http://example.com/next".to_string(), + "GET".to_string(), + "".to_string() + )) ); } #[test] fn test_submit_button_click_handler_can_prevent_default_and_navigate() { - let mut rt = setup_runtime(r#"
"#); - let href = rt.evaluate(r#" + let mut rt = + setup_runtime(r#"
"#); + let href = rt + .evaluate( + r#" const form = document.querySelector('form'); form.addEventListener('submit', (event) => { event.preventDefault(); @@ -6874,11 +8021,17 @@ mod tests { }); document.getElementById('submit').click(); return location.href; - "#).unwrap(); + "#, + ) + .unwrap(); assert_eq!(href, serde_json::json!("http://example.com/submitted")); assert_eq!( rt.take_pending_navigation(), - Some(("http://example.com/submitted".to_string(), "GET".to_string(), "".to_string())) + Some(( + "http://example.com/submitted".to_string(), + "GET".to_string(), + "".to_string() + )) ); } @@ -6886,7 +8039,11 @@ mod tests { fn test_navigator() { let mut rt = setup_runtime(""); let ua = rt.evaluate("navigator.userAgent").unwrap(); - assert!(ua.as_str().unwrap().contains("Chrome"), "UA should contain Chrome: {}", ua); + assert!( + ua.as_str().unwrap().contains("Chrome"), + "UA should contain Chrome: {}", + ua + ); let wd = rt.evaluate("navigator.webdriver").unwrap(); assert_eq!(wd, serde_json::json!(false)); let plugins = rt.evaluate("navigator.plugins.length").unwrap(); @@ -6900,7 +8057,8 @@ mod tests { let mut rt = setup_runtime("Test"); let result = rt .call_function_on("() => document.title", None, &[], true) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.value.unwrap(), serde_json::json!("Test")); } @@ -6911,7 +8069,10 @@ mod tests { serde_json::json!({"value": 10}), serde_json::json!({"value": 20}), ]; - let result = rt.call_function_on("(a, b) => a + b", None, &args, true).await.unwrap(); + let result = rt + .call_function_on("(a, b) => a + b", None, &args, true) + .await + .unwrap(); assert_eq!(result.value.unwrap().as_f64().unwrap() as i64, 30); } @@ -6922,7 +8083,10 @@ mod tests { serde_json::json!({"value": "hello"}), serde_json::json!({"value": " world"}), ]; - let result = rt.call_function_on("(a, b) => a + b", None, &args, true).await.unwrap(); + let result = rt + .call_function_on("(a, b) => a + b", None, &args, true) + .await + .unwrap(); assert_eq!(result.value.unwrap(), serde_json::json!("hello world")); } @@ -6932,7 +8096,8 @@ mod tests { let args = vec![serde_json::json!({"value": {"name": "test", "count": 5}})]; let result = rt .call_function_on("(obj) => obj.name + ':' + obj.count", None, &args, true) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.value.unwrap(), serde_json::json!("test:5")); } @@ -6941,7 +8106,8 @@ mod tests { let mut rt = setup_runtime(""); let result = rt .call_function_on("() => ({a: 1, b: 2})", None, &[], true) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.value.unwrap(), serde_json::json!({"a": 1, "b": 2})); } @@ -6955,12 +8121,19 @@ mod tests { &[], false, ) - .await.unwrap(); + .await + .unwrap(); let oid = result.object_id.unwrap(); let result2 = rt - .call_function_on("function() { return this.getLen(); }", Some(&oid), &[], true) - .await.unwrap(); + .call_function_on( + "function() { return this.getLen(); }", + Some(&oid), + &[], + true, + ) + .await + .unwrap(); assert_eq!(result2.value.unwrap().as_f64().unwrap() as i64, 3); } @@ -6969,7 +8142,8 @@ mod tests { let mut rt = setup_runtime("

Hello

"); let result = rt .evaluate_for_cdp("document.querySelector('h1')", false, false) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.subtype.as_deref(), Some("node")); assert_eq!(result.js_type, "object"); assert!(result.object_id.is_some()); @@ -6983,32 +8157,47 @@ mod tests { assert_eq!(result.class_name, "HTMLDocument"); } - #[tokio::test(flavor = "current_thread")] async fn test_evaluate_for_cdp_awaits_resolved_promise() { let mut rt = setup_runtime(""); - let result = rt.evaluate_for_cdp("Promise.resolve(42)", true, true).await.unwrap(); + let result = rt + .evaluate_for_cdp("Promise.resolve(42)", true, true) + .await + .unwrap(); assert_eq!(result.value.unwrap().as_f64().unwrap() as i64, 42); } #[tokio::test(flavor = "current_thread")] async fn test_evaluate_for_cdp_awaits_timer_promise() { let mut rt = setup_runtime(""); - let result = rt.evaluate_for_cdp("new Promise(resolve => setTimeout(() => resolve('done'), 1))", true, true).await.unwrap(); + let result = rt + .evaluate_for_cdp( + "new Promise(resolve => setTimeout(() => resolve('done'), 1))", + true, + true, + ) + .await + .unwrap(); assert_eq!(result.value.unwrap().as_str().unwrap(), "done"); } #[tokio::test(flavor = "current_thread")] async fn test_evaluate_for_cdp_awaits_async_function() { let mut rt = setup_runtime(""); - let result = rt.evaluate_for_cdp("(async () => 'async-ok')()", true, true).await.unwrap(); + let result = rt + .evaluate_for_cdp("(async () => 'async-ok')()", true, true) + .await + .unwrap(); assert_eq!(result.value.unwrap().as_str().unwrap(), "async-ok"); } #[tokio::test(flavor = "current_thread")] async fn test_evaluate_for_cdp_reports_promise_rejection() { let mut rt = setup_runtime(""); - let err = rt.evaluate_for_cdp("Promise.reject(new Error('boom'))", true, true).await.unwrap_err(); + let err = rt + .evaluate_for_cdp("Promise.reject(new Error('boom'))", true, true) + .await + .unwrap_err(); assert!(err.contains("boom")); } @@ -7023,45 +8212,86 @@ mod tests { &args, true, ) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.value.unwrap().as_f64().unwrap() as i64, 2); } #[test] fn test_inner_html_setter() { let mut rt = setup_runtime(r#"

Old

"#); - rt.execute_script("test", r#" + rt.execute_script( + "test", + r#" var el = document.getElementById('target'); el.innerHTML = 'BoldItalic'; - "#).unwrap(); - let result = rt.evaluate("document.getElementById('target').innerHTML").unwrap(); + "#, + ) + .unwrap(); + let result = rt + .evaluate("document.getElementById('target').innerHTML") + .unwrap(); let html = result.as_str().unwrap(); - assert!(html.contains(""), "innerHTML should contain , got: {}", html); - assert!(html.contains(""), "innerHTML should contain , got: {}", html); - assert!(!html.contains("Old"), "innerHTML should not contain old content, got: {}", html); + assert!( + html.contains(""), + "innerHTML should contain , got: {}", + html + ); + assert!( + html.contains(""), + "innerHTML should contain , got: {}", + html + ); + assert!( + !html.contains("Old"), + "innerHTML should not contain old content, got: {}", + html + ); } #[test] fn test_inner_html_with_nested() { let mut rt = setup_runtime(r#"
"#); - rt.execute_script("test", r#" + rt.execute_script( + "test", + r#" var el = document.getElementById('root'); el.innerHTML = '
  • A
  • B
  • C
'; - "#).unwrap(); - let count = rt.evaluate("document.querySelectorAll('li').length").unwrap(); - assert_eq!(count.as_f64().unwrap() as i64, 3, "Should find 3 li elements after innerHTML set"); + "#, + ) + .unwrap(); + let count = rt + .evaluate("document.querySelectorAll('li').length") + .unwrap(); + assert_eq!( + count.as_f64().unwrap() as i64, + 3, + "Should find 3 li elements after innerHTML set" + ); - let text = rt.evaluate("document.querySelector('li').textContent").unwrap(); + let text = rt + .evaluate("document.querySelector('li').textContent") + .unwrap(); assert_eq!(text, serde_json::json!("A")); } #[test] fn test_input_value() { - let mut rt = setup_runtime(r#"
"#); - let val = rt.evaluate("document.getElementById('name').value").unwrap(); + let mut rt = setup_runtime( + r#"
"#, + ); + let val = rt + .evaluate("document.getElementById('name').value") + .unwrap(); assert_eq!(val, serde_json::json!("initial")); - rt.execute_script("test", "document.getElementById('name').value = 'new value';").unwrap(); - let val2 = rt.evaluate("document.getElementById('name').value").unwrap(); + rt.execute_script( + "test", + "document.getElementById('name').value = 'new value';", + ) + .unwrap(); + let val2 = rt + .evaluate("document.getElementById('name').value") + .unwrap(); assert_eq!(val2, serde_json::json!("new value")); let bio = rt.evaluate("document.getElementById('bio').value").unwrap(); assert_eq!(bio, serde_json::json!("old text")); @@ -7070,14 +8300,18 @@ mod tests { #[test] fn test_sequential_runtime_swap() { let mut rt1 = setup_runtime("

Page1

"); - let title1 = rt1.evaluate("document.querySelector('h1').textContent").unwrap(); + let title1 = rt1 + .evaluate("document.querySelector('h1').textContent") + .unwrap(); assert_eq!(title1, serde_json::json!("Page1")); let dom1 = rt1.take_dom(); drop(rt1); let mut rt2 = setup_runtime("

Page2

"); - let title2 = rt2.evaluate("document.querySelector('h1').textContent").unwrap(); + let title2 = rt2 + .evaluate("document.querySelector('h1').textContent") + .unwrap(); assert_eq!(title2, serde_json::json!("Page2")); drop(rt2); @@ -7087,7 +8321,9 @@ mod tests { rt1b.set_url("http://example.com"); rt1b.set_title("Page1"); rt1b.run_page_init(); - let title1b = rt1b.evaluate("document.querySelector('h1').textContent").unwrap(); + let title1b = rt1b + .evaluate("document.querySelector('h1').textContent") + .unwrap(); assert_eq!(title1b, serde_json::json!("Page1")); } } @@ -7095,10 +8331,15 @@ mod tests { #[test] fn test_checkbox_checked() { let mut rt = setup_runtime(r#""#); - let checked = rt.evaluate("document.getElementById('cb').checked").unwrap(); + let checked = rt + .evaluate("document.getElementById('cb').checked") + .unwrap(); assert_eq!(checked, serde_json::json!(true)); - rt.execute_script("test", "document.getElementById('cb').checked = false;").unwrap(); - let checked2 = rt.evaluate("document.getElementById('cb').checked").unwrap(); + rt.execute_script("test", "document.getElementById('cb').checked = false;") + .unwrap(); + let checked2 = rt + .evaluate("document.getElementById('cb').checked") + .unwrap(); assert_eq!(checked2, serde_json::json!(false)); } @@ -7170,12 +8411,20 @@ mod tests { #[test] fn test_matches_and_closest() { - let mut rt = setup_runtime(r#"
Hi
"#); - let matches = rt.evaluate("document.getElementById('target').matches('span')").unwrap(); + let mut rt = setup_runtime( + r#"
Hi
"#, + ); + let matches = rt + .evaluate("document.getElementById('target').matches('span')") + .unwrap(); assert_eq!(matches, serde_json::json!(true)); - let closest = rt.evaluate("document.getElementById('target').closest('.outer').className").unwrap(); + let closest = rt + .evaluate("document.getElementById('target').closest('.outer').className") + .unwrap(); assert_eq!(closest, serde_json::json!("outer")); - let no_match = rt.evaluate("document.getElementById('target').closest('.nonexistent')").unwrap(); + let no_match = rt + .evaluate("document.getElementById('target').closest('.nonexistent')") + .unwrap(); assert_eq!(no_match, serde_json::Value::Null); } @@ -7215,8 +8464,8 @@ mod tests { assert_eq!( result, serde_json::json!([ - true, true, true, "string", "function", "SECTION", "src", "clone", - "original", 0, true, true, true, "source", "changed", 1 + true, true, true, "string", "function", "SECTION", "src", "clone", "original", 0, + true, true, true, "source", "changed", 1 ]) ); } @@ -7261,9 +8510,23 @@ mod tests { assert_eq!( result, serde_json::json!([ - true, true, "HTML", "string", "function", 4, true, true, true, - true, "original", "fr", "en", "clone text", "original text", - "source", "changed" + true, + true, + "HTML", + "string", + "function", + 4, + true, + true, + true, + true, + "original", + "fr", + "en", + "clone text", + "original text", + "source", + "changed" ]) ); } @@ -7280,17 +8543,21 @@ mod tests { let mut rt = setup_runtime(""); let obj = rt .call_function_on("() => ({ x: 42 })", None, &[], false) - .await.unwrap(); + .await + .unwrap(); let oid = obj.object_id.unwrap(); let args = vec![serde_json::json!({"objectId": oid})]; let result = rt .call_function_on("(obj) => obj.x * 2", None, &args, true) - .await.unwrap(); + .await + .unwrap(); assert_eq!(result.value.unwrap().as_f64().unwrap() as i64, 84); } - fn setup_runtime_with_cookies(html: &str) -> (ObscuraJsRuntime, std::sync::Arc) { + fn setup_runtime_with_cookies( + html: &str, + ) -> (ObscuraJsRuntime, std::sync::Arc) { let dom = obscura_dom::parse_html(html); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let mut rt = ObscuraJsRuntime::new(); @@ -7310,8 +8577,16 @@ mod tests { jar.set_cookie("theme=dark; Path=/", &url); let result = rt.evaluate("document.cookie").unwrap(); let cookie_str = result.as_str().unwrap(); - assert!(cookie_str.contains("session=abc123"), "expected session cookie, got: {}", cookie_str); - assert!(cookie_str.contains("theme=dark"), "expected theme cookie, got: {}", cookie_str); + assert!( + cookie_str.contains("session=abc123"), + "expected session cookie, got: {}", + cookie_str + ); + assert!( + cookie_str.contains("theme=dark"), + "expected theme cookie, got: {}", + cookie_str + ); } #[test] @@ -7322,8 +8597,16 @@ mod tests { jar.set_cookie("secret=token; Path=/; HttpOnly", &url); let result = rt.evaluate("document.cookie").unwrap(); let cookie_str = result.as_str().unwrap(); - assert!(cookie_str.contains("visible=yes"), "expected visible cookie, got: {}", cookie_str); - assert!(!cookie_str.contains("secret"), "httpOnly cookie should not be visible to JS, got: {}", cookie_str); + assert!( + cookie_str.contains("visible=yes"), + "expected visible cookie, got: {}", + cookie_str + ); + assert!( + !cookie_str.contains("secret"), + "httpOnly cookie should not be visible to JS, got: {}", + cookie_str + ); } #[test] @@ -7334,7 +8617,11 @@ mod tests { let result = rt.evaluate("document.cookie").unwrap(); assert!(result.as_str().unwrap().contains("foo=bar")); let header = jar.get_cookie_header(&url); - assert!(header.contains("foo=bar"), "cookie should be in jar, got: {}", header); + assert!( + header.contains("foo=bar"), + "cookie should be in jar, got: {}", + header + ); } #[test] @@ -7342,10 +8629,19 @@ mod tests { let (mut rt, jar) = setup_runtime_with_cookies(""); let url = url::Url::parse("http://example.com/test").unwrap(); rt.evaluate("document.cookie = 'temp=val; Path=/'").unwrap(); - assert!(rt.evaluate("document.cookie").unwrap().as_str().unwrap().contains("temp=val")); + assert!(rt + .evaluate("document.cookie") + .unwrap() + .as_str() + .unwrap() + .contains("temp=val")); rt.evaluate("document.cookie = 'temp=; Max-Age=0'").unwrap(); let result = rt.evaluate("document.cookie").unwrap(); - assert!(!result.as_str().unwrap().contains("temp="), "cookie should be deleted, got: {}", result); + assert!( + !result.as_str().unwrap().contains("temp="), + "cookie should be deleted, got: {}", + result + ); assert!(!jar.get_cookie_header(&url).contains("temp=")); } @@ -7354,11 +8650,20 @@ mod tests { let (mut rt, jar) = setup_runtime_with_cookies(""); let url = url::Url::parse("http://example.com/test").unwrap(); jar.set_cookie("server_sid=xyz; Path=/", &url); - rt.evaluate("document.cookie = 'client_pref=light'").unwrap(); + rt.evaluate("document.cookie = 'client_pref=light'") + .unwrap(); let result = rt.evaluate("document.cookie").unwrap(); let cookie_str = result.as_str().unwrap(); - assert!(cookie_str.contains("server_sid=xyz"), "expected server cookie, got: {}", cookie_str); - assert!(cookie_str.contains("client_pref=light"), "expected client cookie, got: {}", cookie_str); + assert!( + cookie_str.contains("server_sid=xyz"), + "expected server cookie, got: {}", + cookie_str + ); + assert!( + cookie_str.contains("client_pref=light"), + "expected client cookie, got: {}", + cookie_str + ); } #[test] @@ -7381,8 +8686,16 @@ mod tests { rt.evaluate("document.write('
Added
')").unwrap(); let html = rt.evaluate("document.body.innerHTML").unwrap(); let body = html.as_str().unwrap(); - assert!(body.contains("Existing"), "existing content should remain, got: {}", body); - assert!(body.contains("Added"), "written content should appear, got: {}", body); + assert!( + body.contains("Existing"), + "existing content should remain, got: {}", + body + ); + assert!( + body.contains("Added"), + "written content should appear, got: {}", + body + ); } #[test] @@ -7396,7 +8709,8 @@ mod tests { #[test] fn test_document_write_multiple_args() { let mut rt = setup_runtime(""); - rt.evaluate("document.write('Hello', ' ', 'World')").unwrap(); + rt.evaluate("document.write('Hello', ' ', 'World')") + .unwrap(); let text = rt.evaluate("document.body.textContent").unwrap(); assert_eq!(text.as_str().unwrap().trim(), "Hello World"); } @@ -7412,37 +8726,62 @@ mod tests { #[test] fn test_document_write_html_elements() { let mut rt = setup_runtime(""); - rt.evaluate(r#"document.write('

Test

Para

')"#).unwrap(); - let h1 = rt.evaluate("document.querySelector('h1').textContent").unwrap(); + rt.evaluate(r#"document.write('

Test

Para

')"#) + .unwrap(); + let h1 = rt + .evaluate("document.querySelector('h1').textContent") + .unwrap(); assert_eq!(h1.as_str().unwrap(), "Test"); - let p = rt.evaluate("document.querySelector('p').textContent").unwrap(); + let p = rt + .evaluate("document.querySelector('p').textContent") + .unwrap(); assert_eq!(p.as_str().unwrap(), "Para"); } #[test] fn test_url_relative_resolution() { let mut rt = setup_runtime(""); - let result = rt.evaluate("new URL('data.json', 'http://example.com/path/page.html').href").unwrap(); - assert_eq!(result.as_str().unwrap(), "http://example.com/path/data.json"); + let result = rt + .evaluate("new URL('data.json', 'http://example.com/path/page.html').href") + .unwrap(); + assert_eq!( + result.as_str().unwrap(), + "http://example.com/path/data.json" + ); - let result = rt.evaluate("new URL('/api/data', 'http://example.com/path/page.html').href").unwrap(); + let result = rt + .evaluate("new URL('/api/data', 'http://example.com/path/page.html').href") + .unwrap(); assert_eq!(result.as_str().unwrap(), "http://example.com/api/data"); - let result = rt.evaluate("new URL('https://other.com/foo', 'http://example.com/bar').href").unwrap(); + let result = rt + .evaluate("new URL('https://other.com/foo', 'http://example.com/bar').href") + .unwrap(); assert_eq!(result.as_str().unwrap(), "https://other.com/foo"); - let result = rt.evaluate("new URL('sub/file.js', 'http://example.com/a/b/c.html').href").unwrap(); - assert_eq!(result.as_str().unwrap(), "http://example.com/a/b/sub/file.js"); + let result = rt + .evaluate("new URL('sub/file.js', 'http://example.com/a/b/c.html').href") + .unwrap(); + assert_eq!( + result.as_str().unwrap(), + "http://example.com/a/b/sub/file.js" + ); - let result = rt.evaluate("new URL('api.json', 'http://localhost:8080/dir/index.html').href").unwrap(); - assert_eq!(result.as_str().unwrap(), "http://localhost:8080/dir/api.json"); + let result = rt + .evaluate("new URL('api.json', 'http://localhost:8080/dir/index.html').href") + .unwrap(); + assert_eq!( + result.as_str().unwrap(), + "http://localhost:8080/dir/api.json" + ); } #[tokio::test(flavor = "current_thread")] async fn test_fetch_url_input_decodes_binary_body_base64() { let mut rt = setup_runtime(""); - let result = rt.call_function_on_for_cdp( - r#"async () => { + let result = rt + .call_function_on_for_cdp( + r#"async () => { const originalFetchOp = Deno.core.ops.op_fetch_url; try { Deno.core.ops.op_fetch_url = (url) => { @@ -7461,11 +8800,13 @@ mod tests { Deno.core.ops.op_fetch_url = originalFetchOp; } }"#, - None, - &[], - true, - true, - ).await.unwrap(); + None, + &[], + true, + true, + ) + .await + .unwrap(); assert_eq!( result.value.unwrap(), @@ -7553,9 +8894,8 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn dynamic_linked_stylesheet_enters_the_live_dom_with_imports_rebased() { - let mut rt = setup_runtime( - "
", - ); + let mut rt = + setup_runtime("
"); let result = rt .call_function_on_for_cdp( r#"async () => { @@ -7665,26 +9005,33 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn test_response_array_buffer_preserves_typed_array_view() { let mut rt = setup_runtime(""); - let result = rt.call_function_on_for_cdp( - r#"async () => { + let result = rt + .call_function_on_for_cdp( + r#"async () => { const bytes = new Uint8Array([9, 0, 97, 115, 109, 1, 8]); const response = new Response(bytes.subarray(1, 6)); return Array.from(new Uint8Array(await response.arrayBuffer())); }"#, - None, - &[], - true, - true, - ).await.unwrap(); + None, + &[], + true, + true, + ) + .await + .unwrap(); - assert_eq!(result.value.unwrap(), serde_json::json!([0, 97, 115, 109, 1])); + assert_eq!( + result.value.unwrap(), + serde_json::json!([0, 97, 115, 109, 1]) + ); } #[tokio::test(flavor = "current_thread")] async fn test_wasm_instantiate_streaming_uses_response_array_buffer() { let mut rt = setup_runtime(""); - let result = rt.call_function_on_for_cdp( - r#"async () => { + let result = rt + .call_function_on_for_cdp( + r#"async () => { const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]); const result = await WebAssembly.instantiateStreaming( Promise.resolve(new Response(bytes)), @@ -7692,11 +9039,13 @@ mod tests { ); return result.instance instanceof WebAssembly.Instance; }"#, - None, - &[], - true, - true, - ).await.unwrap(); + None, + &[], + true, + true, + ) + .await + .unwrap(); assert_eq!(result.value.unwrap(), serde_json::json!(true)); } @@ -7704,9 +9053,9 @@ mod tests { #[test] fn test_text_decoder_respects_typed_array_view() { let mut rt = setup_runtime(""); - let result = rt.evaluate( - "new TextDecoder().decode(new Uint8Array([65, 66, 67]).subarray(1, 2))" - ).unwrap(); + let result = rt + .evaluate("new TextDecoder().decode(new Uint8Array([65, 66, 67]).subarray(1, 2))") + .unwrap(); assert_eq!(result.as_str().unwrap(), "B"); } @@ -7733,18 +9082,18 @@ mod tests { #[test] fn test_xml_serializer_doctype() { let mut rt = setup_runtime(""); - let result = rt.evaluate( - "new XMLSerializer().serializeToString(document.doctype)" - ).unwrap(); + let result = rt + .evaluate("new XMLSerializer().serializeToString(document.doctype)") + .unwrap(); assert_eq!(result.as_str().unwrap(), ""); } #[test] fn test_xml_serializer_element() { let mut rt = setup_runtime(r#"
Hello
"#); - let result = rt.evaluate( - "new XMLSerializer().serializeToString(document.getElementById('x'))" - ).unwrap(); + let result = rt + .evaluate("new XMLSerializer().serializeToString(document.getElementById('x'))") + .unwrap(); let html = result.as_str().unwrap(); assert!(html.contains("

Title

Sub

Body

"); + let mut rt = + setup_runtime("

Title

Sub

Body

"); let md = rt .evaluate(crate::HTML_TO_MARKDOWN_JS) .unwrap() @@ -8015,7 +9362,8 @@ mod tests { #[test] fn test_page_content_puppeteer_pattern() { - let mut rt = setup_runtime("

Test

"); + let mut rt = + setup_runtime("

Test

"); let result = rt.evaluate( "(function() { let retVal = ''; if (document.doctype) retVal = new XMLSerializer().serializeToString(document.doctype); if (document.documentElement) retVal += document.documentElement.outerHTML; return retVal; })()" ).unwrap(); @@ -8037,7 +9385,9 @@ mod tests { #[test] fn test_element_from_point_in_viewport_returns_body() { let mut rt = setup_runtime("

Hi

"); - let tag = rt.evaluate("document.elementFromPoint(10, 10)?.tagName").unwrap(); + let tag = rt + .evaluate("document.elementFromPoint(10, 10)?.tagName") + .unwrap(); assert_eq!(tag, serde_json::json!("BODY")); } @@ -8048,16 +9398,22 @@ mod tests { assert_eq!(neg_x, serde_json::Value::Null); let neg_y = rt.evaluate("document.elementFromPoint(10, -1)").unwrap(); assert_eq!(neg_y, serde_json::Value::Null); - let huge = rt.evaluate("document.elementFromPoint(99999, 99999)").unwrap(); + let huge = rt + .evaluate("document.elementFromPoint(99999, 99999)") + .unwrap(); assert_eq!(huge, serde_json::Value::Null); } #[test] fn test_elements_from_point_returns_array() { let mut rt = setup_runtime(""); - let len_in = rt.evaluate("document.elementsFromPoint(10, 10).length").unwrap(); + let len_in = rt + .evaluate("document.elementsFromPoint(10, 10).length") + .unwrap(); assert_eq!(len_in.as_f64().unwrap() as i64, 1); - let len_out = rt.evaluate("document.elementsFromPoint(-1, -1).length").unwrap(); + let len_out = rt + .evaluate("document.elementsFromPoint(-1, -1).length") + .unwrap(); assert_eq!(len_out.as_f64().unwrap() as i64, 0); } @@ -8066,7 +9422,9 @@ mod tests { let mut rt = setup_runtime(""); let nan = rt.evaluate("document.elementFromPoint(NaN, 10)").unwrap(); assert_eq!(nan, serde_json::Value::Null); - let inf = rt.evaluate("document.elementFromPoint(Infinity, 10)").unwrap(); + let inf = rt + .evaluate("document.elementFromPoint(Infinity, 10)") + .unwrap(); assert_eq!(inf, serde_json::Value::Null); } @@ -8249,9 +9607,7 @@ mod tests { let base = spawn_one_response_server("404 Not Found", "not found"); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( - jar, - None, - true, + jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base)); rt.set_http_client(client); @@ -8345,9 +9701,7 @@ mod tests { let (base, requests) = spawn_module_graph_server(ModuleGraphFixture::RedirectedChild); let jar = std::sync::Arc::new(obscura_net::CookieJar::new()); let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options( - jar, - None, - true, + jar, None, true, )); let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/", base)); rt.set_http_client(client); @@ -8415,10 +9769,20 @@ mod tests { serde_json::json!(["prefix-static", "exact-dynamic"]), ); let paths = (0..2) - .map(|_| requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap()) + .map(|_| { + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap() + }) .collect::>(); - assert!(paths.contains(&"/vendor/pkg/feature.js".to_string()), "{paths:?}"); - assert!(paths.contains(&"/vendor/dynamic.js".to_string()), "{paths:?}"); + assert!( + paths.contains(&"/vendor/pkg/feature.js".to_string()), + "{paths:?}" + ); + assert!( + paths.contains(&"/vendor/dynamic.js".to_string()), + "{paths:?}" + ); } #[tokio::test(flavor = "current_thread")] @@ -8436,13 +9800,17 @@ mod tests { ) .unwrap(); - rt.load_module(&format!("{}/entry.js", base), 1_000).await.unwrap(); + rt.load_module(&format!("{}/entry.js", base), 1_000) + .await + .unwrap(); assert_eq!( rt.evaluate("globalThis.__root_module_identity").unwrap(), serde_json::json!("entry"), ); assert_eq!( - requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap(), "/entry.js", ); } @@ -8454,15 +9822,20 @@ mod tests { "globalThis.__first_inline_url = import.meta.url;", "https://example.com/base/", 1_000, - ).await.unwrap(); + ) + .await + .unwrap(); rt.load_inline_module( "globalThis.__second_inline_url = import.meta.url;", "https://example.com/base/", 1_000, - ).await.unwrap(); + ) + .await + .unwrap(); assert_eq!( - rt.evaluate("[globalThis.__first_inline_url, globalThis.__second_inline_url]").unwrap(), + rt.evaluate("[globalThis.__first_inline_url, globalThis.__second_inline_url]") + .unwrap(), serde_json::json!(["https://example.com/base/", "https://example.com/base/"]) ); } @@ -8478,9 +9851,11 @@ mod tests { let mut request = [0u8; 2048]; let length = stream.read(&mut request).unwrap(); let path = String::from_utf8_lossy(&request[..length]) - .lines().next() + .lines() + .next() .and_then(|line| line.split_ascii_whitespace().nth(1)) - .unwrap_or("/").to_string(); + .unwrap_or("/") + .to_string(); let body = "export const value = 'scoped-classic';"; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", @@ -8501,18 +9876,24 @@ mod tests { rt.add_import_map( &format!(r#"{{"scopes":{{"{base}/classic/":{{"pkg":"{base}/scoped.js"}}}}}}"#), &format!("{base}/page/index.html"), - ).unwrap(); + ) + .unwrap(); rt.execute_script( &format!("{base}/classic/entry.js"), "document.documentElement.setAttribute('data-classic-op', 'ran'); \ import('pkg').then(module => { globalThis.__classic_import = module.value; });", - ).unwrap(); + ) + .unwrap(); rt.run_event_loop().await.unwrap(); - assert_eq!(rt.evaluate("globalThis.__classic_import").unwrap(), serde_json::json!("scoped-classic")); assert_eq!( - rt.evaluate("document.documentElement.getAttribute('data-classic-op')").unwrap(), + rt.evaluate("globalThis.__classic_import").unwrap(), + serde_json::json!("scoped-classic") + ); + assert_eq!( + rt.evaluate("document.documentElement.getAttribute('data-classic-op')") + .unwrap(), serde_json::json!("ran") ); assert_eq!(request_thread.join().unwrap(), "/scoped.js"); @@ -8525,12 +9906,17 @@ mod tests { "https://example.test/hang.js", "while (true) {}", std::time::Duration::from_millis(20), - ).unwrap(); + ) + .unwrap(); rt.execute_script( "https://example.test/after-timeout.js", "globalThis.__after_timeout = true;", - ).unwrap(); - assert_eq!(rt.evaluate("globalThis.__after_timeout").unwrap(), serde_json::json!(true)); + ) + .unwrap(); + assert_eq!( + rt.evaluate("globalThis.__after_timeout").unwrap(), + serde_json::json!(true) + ); } #[tokio::test(flavor = "current_thread")] @@ -8559,8 +9945,7 @@ mod tests { .await .unwrap_err(); assert!( - error.contains("Inline module eval error") - && error.contains("module-evaluation-boom"), + error.contains("Inline module eval error") && error.contains("module-evaluation-boom"), "expected evaluation error, got: {}", error ); @@ -8740,9 +10125,7 @@ mod tests { .unwrap(); assert_eq!( result, - serde_json::json!([ - "function", "function", "function", "function", true, 1, true - ]) + serde_json::json!(["function", "function", "function", "function", true, 1, true]) ); } @@ -8895,7 +10278,9 @@ mod tests { #[test] fn console_log_error_does_not_trigger_prepare_stack_trace() { let mut rt = setup_runtime("
"); - let result = rt.evaluate(r#" + let result = rt + .evaluate( + r#" let called = false; const saved = Error.prepareStackTrace; Error.prepareStackTrace = function() { called = true; return saved; }; @@ -8903,7 +10288,9 @@ mod tests { console.log(e); Error.prepareStackTrace = saved; return called; - "#).unwrap(); + "#, + ) + .unwrap(); assert_eq!(result, serde_json::json!(false)); } @@ -9013,9 +10400,7 @@ mod tests { .unwrap(); assert_eq!( result, - serde_json::json!([ - true, true, true, 1, 1, 3, "Hello", -2, 80, 1, true, "metadata" - ]) + serde_json::json!([true, true, true, 1, 1, 3, "Hello", -2, 80, 1, true, "metadata"]) ); } diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 453ab5e..6471a37 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -9,7 +9,9 @@ use obscura_dom::selector::{CompiledSelector, Matcher, SelectorKey}; use obscura_dom::tree::{DomTree, NodeId}; +use std::borrow::Cow; use std::collections::HashMap; +use std::sync::Arc; use crate::LayoutStyle; @@ -71,7 +73,11 @@ struct ContainerSizeFeature { } #[derive(Clone, Copy, Debug, PartialEq)] -enum ContainerQueryLength { Px(f32), Em(f32), Rem(f32) } +enum ContainerQueryLength { + Px(f32), + Em(f32), + Rem(f32), +} struct ParsedRule { selector: String, @@ -82,23 +88,117 @@ struct ParsedRule { struct Rule { sel: CompiledSelector, + specificity: u32, normal_decls: String, important_decls: String, + normal_flags: DeclarationStreamFlags, + important_flags: DeclarationStreamFlags, + candidate_slot: u32, /// Source order, for breaking specificity ties (later wins). order: usize, container_condition_id: ContainerConditionId, layer: Option, } +const NO_CANDIDATE_SLOT: u32 = u32::MAX; + +fn is_root_element(tree: &DomTree, nid: NodeId) -> bool { + tree.get_node(nid) + .and_then(|node| node.parent) + .and_then(|parent| tree.get_node(parent)) + .is_some_and(|parent| parent.is_document()) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct DeclarationStreamFlags { + has_custom_properties: bool, + has_var: bool, + has_color_scheme: bool, + has_animation: bool, +} + +/// Cache the declaration features which otherwise require an extra stream +/// walk or an allocated substituted copy for every matching element. This is +/// computed once while the stylesheet is indexed; false positives would only +/// cost work, while exact declaration-name checks keep the skip paths sound. +fn declaration_stream_flags(css: &str) -> DeclarationStreamFlags { + let mut flags = DeclarationStreamFlags::default(); + for declaration in crate::style::split_declarations(css) { + let Some((name, value)) = declaration.split_once(':') else { + continue; + }; + let name = name.trim(); + flags.has_custom_properties |= name.starts_with("--") && name.len() > 2; + flags.has_var |= value.contains("var("); + if name.eq_ignore_ascii_case("color-scheme") { + flags.has_color_scheme = true; + } + if name.eq_ignore_ascii_case("animation") + || name + .get(..10) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("animation-")) + { + flags.has_animation = true; + } + } + flags +} + struct PseudoRule { sel: CompiledSelector, + specificity: u32, normal_decls: String, important_decls: String, + normal_flags: DeclarationStreamFlags, + important_flags: DeclarationStreamFlags, + candidate_slot: u32, order: usize, container_condition_id: ContainerConditionId, layer: Option, } +#[derive(Default)] +struct PseudoRuleMap { + rules: Vec, + by_root: Vec, + by_id: HashMap>, + by_class: HashMap>, + by_attribute: HashMap>, + by_local: HashMap>, + universal: Vec, + candidate_slot_count: usize, +} + +impl PseudoRuleMap { + fn push(&mut self, mut rule: PseudoRule) { + let index = self.rules.len(); + if rule.sel.candidate_keys().len() > 1 { + rule.candidate_slot = u32::try_from(self.candidate_slot_count) + .expect("pseudo selector candidate slot count exceeds u32"); + self.candidate_slot_count += 1; + } + for key in rule.sel.candidate_keys() { + match key { + SelectorKey::Root => self.by_root.push(index), + SelectorKey::Id(value) => self.by_id.entry(value.clone()).or_default().push(index), + SelectorKey::Class(value) => { + self.by_class.entry(value.clone()).or_default().push(index) + } + SelectorKey::Attribute(value) => self + .by_attribute + .entry(value.clone()) + .or_default() + .push(index), + SelectorKey::Local(value) => { + self.by_local.entry(value.clone()).or_default().push(index) + } + SelectorKey::Universal => self.universal.push(index), + } + } + self.rules.push(rule); + } +} + /// A cascade layer's first-declaration position at each nesting level. /// /// The terminal (direct declarations in a parent layer) is intentionally not @@ -311,8 +411,7 @@ impl PartialEq for ContainerBox { match self.available_type { crate::ContainerType::Normal => true, crate::ContainerType::InlineSize => { - self.content_width == other.content_width - && self.font_size == other.font_size + self.content_width == other.content_width && self.font_size == other.font_size } crate::ContainerType::Size => { self.content_width == other.content_width @@ -426,7 +525,8 @@ impl<'a> ContainerQueryEvaluator<'a> { condition: ContainerConditionId, kind: ContainerQuerySubjectKind, ) -> bool { - self.evaluate_condition_chain(sheet, subject, condition, kind).truth + self.evaluate_condition_chain(sheet, subject, condition, kind) + .truth == ContainerQueryTruth::True } @@ -472,8 +572,7 @@ impl<'a> ContainerQueryEvaluator<'a> { self.cache.insert(key, decision.clone()); return decision; } - let parent = - self.evaluate_condition_chain(sheet, subject, node.parent, kind); + let parent = self.evaluate_condition_chain(sheet, subject, node.parent, kind); selected_containers.extend(parent.selected_containers); let decision = ContainerQueryDecision { truth: own_truth.and(parent.truth), @@ -505,9 +604,7 @@ impl<'a> ContainerQueryEvaluator<'a> { let parent = self.tree.get_node(id).and_then(|node| node.parent); if let Some(container) = self.snapshot.boxes.get(&id) { let supports_axis = match container.container_type { - crate::ContainerType::Normal => { - !required_axes.inline && !required_axes.block - } + crate::ContainerType::Normal => !required_axes.inline && !required_axes.block, crate::ContainerType::InlineSize => !required_axes.block, crate::ContainerType::Size => true, }; @@ -531,11 +628,11 @@ impl<'a> ContainerQueryEvaluator<'a> { .condition .as_ref() .map_or(ContainerQueryTruth::True, |condition| { - evaluate_container_query_expr( - condition, - container, - self.snapshot.root_font_size, - ) + evaluate_container_query_expr( + condition, + container, + self.snapshot.root_font_size, + ) }); return (truth, Some(id)); } @@ -571,15 +668,14 @@ fn container_query_required_axes(expr: &ContainerQueryExpr) -> ContainerQueryReq ContainerQueryExpr::Unknown => ContainerQueryRequiredAxes::default(), ContainerQueryExpr::Not(inner) => container_query_required_axes(inner), ContainerQueryExpr::And(items) | ContainerQueryExpr::Or(items) => { - items.iter().fold( - ContainerQueryRequiredAxes::default(), - |mut axes, item| { + items + .iter() + .fold(ContainerQueryRequiredAxes::default(), |mut axes, item| { let item = container_query_required_axes(item); axes.inline |= item.inline; axes.block |= item.block; axes - }, - ) + }) } } } @@ -665,15 +761,94 @@ pub struct Stylesheet { /// property-specific segments at the stylesheet's explicit sample time. keyframes: HashMap, animation_sample_time: crate::AnimationSampleTime, + by_root: Vec, by_id: HashMap>, by_class: HashMap>, + by_attribute: HashMap>, by_local: HashMap>, universal: Vec, + candidate_slot_count: usize, /// `sel::before` / `sel::after` rules matched against their ordinary base /// selector. Keeping their full declaration cascade supports both literal /// generated text and positioned decorative boxes. - before_rules: Vec, - after_rules: Vec, + before_rules: PseudoRuleMap, + after_rules: PseudoRuleMap, +} + +const MAX_STYLESHEET_CACHE_SOURCE_BYTES: usize = 8 * 1024 * 1024; +const MAX_STYLESHEET_CACHE_RULES: usize = 100_000; + +/// One document-local compiled author stylesheet. +/// +/// DOM mutations still run the complete cascade and layout against the live +/// tree. This cache retains only source parsing, selector compilation, and +/// candidate indexing, whose inputs are the ordered CSS text and viewport. +/// Keeping a single exact-key entry prevents cross-document growth and avoids +/// hash-collision correctness risks. Pathological source sets above the byte +/// bound are parsed normally but never retained. +#[derive(Default)] +pub struct StylesheetCache { + entry: Option, + hits: u64, + misses: u64, +} + +struct CachedStylesheet { + sources: Vec, + viewport_bits: (u32, u32), + source_bytes: usize, + sheet: Arc, +} + +impl StylesheetCache { + pub(crate) fn get_or_parse( + &mut self, + tree: &DomTree, + sources: &[String], + viewport: (f32, f32), + ) -> (Arc, bool) { + let viewport_bits = (viewport.0.to_bits(), viewport.1.to_bits()); + if let Some(entry) = self.entry.as_ref() { + if entry.viewport_bits == viewport_bits && entry.sources == sources { + self.hits = self.hits.saturating_add(1); + return (Arc::clone(&entry.sheet), true); + } + } + + self.misses = self.misses.saturating_add(1); + let sheet = Arc::new(Stylesheet::parse_for_viewport(tree, sources, viewport)); + let source_bytes = sources + .iter() + .try_fold(0usize, |total, source| total.checked_add(source.len())); + let compiled_rules = + sheet.rules.len() + sheet.before_rules.rules.len() + sheet.after_rules.rules.len(); + if source_bytes.is_some_and(|bytes| bytes <= MAX_STYLESHEET_CACHE_SOURCE_BYTES) + && compiled_rules <= MAX_STYLESHEET_CACHE_RULES + { + let source_bytes = source_bytes.unwrap_or_default(); + self.entry = Some(CachedStylesheet { + sources: sources.to_vec(), + viewport_bits, + source_bytes, + sheet: Arc::clone(&sheet), + }); + } else { + self.entry = None; + } + (sheet, false) + } + + pub fn hit_count(&self) -> u64 { + self.hits + } + + pub fn miss_count(&self) -> u64 { + self.misses + } + + pub fn retained_source_bytes(&self) -> usize { + self.entry.as_ref().map_or(0, |entry| entry.source_bytes) + } } impl Stylesheet { @@ -690,9 +865,9 @@ impl Stylesheet { if id == ContainerConditionId::NONE { return true; } - evaluator.as_deref_mut().is_some_and(|evaluator| { - evaluator.condition_matches(self, subject, id, kind) - }) + evaluator + .as_deref_mut() + .is_some_and(|evaluator| evaluator.condition_matches(self, subject, id, kind)) } pub(crate) fn has_container_queries(&self) -> bool { @@ -701,8 +876,9 @@ impl Stylesheet { .any(|rule| rule.container_condition_id != ContainerConditionId::NONE) || self .before_rules + .rules .iter() - .chain(&self.after_rules) + .chain(&self.after_rules.rules) .any(|rule| rule.container_condition_id != ContainerConditionId::NONE) } @@ -733,11 +909,7 @@ impl Stylesheet { /// same dimensions as layout and page JavaScript; filtering them against a /// fixed desktop width made responsive frameworks build one DOM while the /// renderer applied another breakpoint. - pub fn parse_for_viewport( - tree: &DomTree, - sources: &[String], - viewport: (f32, f32), - ) -> Self { + pub fn parse_for_viewport(tree: &DomTree, sources: &[String], viewport: (f32, f32)) -> Self { Self::parse_for_viewport_at_animation_time( tree, sources, @@ -761,17 +933,19 @@ impl Stylesheet { }], keyframes: HashMap::new(), animation_sample_time, + by_root: Vec::new(), by_id: HashMap::new(), by_class: HashMap::new(), + by_attribute: HashMap::new(), by_local: HashMap::new(), universal: Vec::new(), - before_rules: Vec::new(), - after_rules: Vec::new(), + candidate_slot_count: 0, + before_rules: PseudoRuleMap::default(), + after_rules: PseudoRuleMap::default(), }; let mut order = 0usize; let mut layers = LayerRegistry::default(); - let mut keyframe_winners = - HashMap::, bool, usize)>::new(); + let mut keyframe_winners = HashMap::, bool, usize)>::new(); for src in sources { let parsed = parse_stylesheet_for_viewport_preserving_containers_in_layer( src, @@ -811,10 +985,7 @@ impl Stylesheet { if replaces { let keyframes = compile_keyframe_body(&decls); sheet.keyframes.insert(name.to_string(), keyframes); - keyframe_winners.insert( - name.to_string(), - (layer.clone(), prefixed, order), - ); + keyframe_winners.insert(name.to_string(), (layer.clone(), prefixed, order)); } order += 1; continue; @@ -832,10 +1003,17 @@ impl Stylesheet { if let Some(sel) = tree.compile_rule_selector(base) { let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); + let normal_flags = declaration_stream_flags(&normal_decls); + let important_flags = declaration_stream_flags(&important_decls); + let specificity = sel.specificity(); sheet.before_rules.push(PseudoRule { sel, + specificity, normal_decls, important_decls, + normal_flags, + important_flags, + candidate_slot: NO_CANDIDATE_SLOT, order, container_condition_id, layer, @@ -848,10 +1026,17 @@ impl Stylesheet { if let Some(sel) = tree.compile_rule_selector(base) { let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); + let normal_flags = declaration_stream_flags(&normal_decls); + let important_flags = declaration_stream_flags(&important_decls); + let specificity = sel.specificity(); sheet.after_rules.push(PseudoRule { sel, + specificity, normal_decls, important_decls, + normal_flags, + important_flags, + candidate_slot: NO_CANDIDATE_SLOT, order, container_condition_id, layer, @@ -860,19 +1045,46 @@ impl Stylesheet { order += 1; continue; } - let Some(sel) = tree.compile_rule_selector(&selector) else { continue }; + let Some(sel) = tree.compile_rule_selector(&selector) else { + continue; + }; let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); + let specificity = sel.specificity(); + let normal_flags = declaration_stream_flags(&normal_decls); + let important_flags = declaration_stream_flags(&important_decls); let idx = sheet.rules.len(); - match sel.key() { - SelectorKey::Id(v) => sheet.by_id.entry(v.clone()).or_default().push(idx), - SelectorKey::Class(v) => sheet.by_class.entry(v.clone()).or_default().push(idx), - SelectorKey::Local(v) => sheet.by_local.entry(v.clone()).or_default().push(idx), - SelectorKey::Universal => sheet.universal.push(idx), + let candidate_slot = if sel.candidate_keys().len() > 1 { + let slot = u32::try_from(sheet.candidate_slot_count) + .expect("selector candidate slot count exceeds u32"); + sheet.candidate_slot_count += 1; + slot + } else { + NO_CANDIDATE_SLOT + }; + for key in sel.candidate_keys() { + match key { + SelectorKey::Root => sheet.by_root.push(idx), + SelectorKey::Id(v) => sheet.by_id.entry(v.clone()).or_default().push(idx), + SelectorKey::Class(v) => { + sheet.by_class.entry(v.clone()).or_default().push(idx) + } + SelectorKey::Attribute(v) => { + sheet.by_attribute.entry(v.clone()).or_default().push(idx) + } + SelectorKey::Local(v) => { + sheet.by_local.entry(v.clone()).or_default().push(idx) + } + SelectorKey::Universal => sheet.universal.push(idx), + } } sheet.rules.push(Rule { sel, + specificity, normal_decls, important_decls, + normal_flags, + important_flags, + candidate_slot, order, container_condition_id, layer, @@ -891,14 +1103,7 @@ impl Stylesheet { props: &HashMap, host_style: &LayoutStyle, ) -> (Option, Option) { - self.pseudo_styles_internal( - tree, - matcher, - nid, - props, - host_style, - None, - ) + self.pseudo_styles_internal(tree, matcher, nid, props, host_style, None) } pub(crate) fn pseudo_styles_with_container_queries( @@ -910,14 +1115,7 @@ impl Stylesheet { host_style: &LayoutStyle, evaluator: &mut ContainerQueryEvaluator<'_>, ) -> (Option, Option) { - self.pseudo_styles_internal( - tree, - matcher, - nid, - props, - host_style, - Some(evaluator), - ) + self.pseudo_styles_internal(tree, matcher, nid, props, host_style, Some(evaluator)) } fn pseudo_styles_internal( @@ -929,49 +1127,125 @@ impl Stylesheet { host_style: &LayoutStyle, mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>, ) -> (Option, Option) { - let mut build = |rules: &[PseudoRule], matcher: &mut Matcher| { - let mut matched: Vec<(u32, usize, &PseudoRule)> = Vec::new(); - for rule in rules { - // Container lookup is an ancestor walk. Keep it behind the - // selector match so unrelated pseudo rules remain cheap. - if matcher.matches(tree, nid, &rule.sel) - && self.container_condition_is_active( - rule.container_condition_id, - nid, - ContainerQuerySubjectKind::OriginatingPseudo, - &mut evaluator, - ) - { - matched.push((rule.sel.specificity(), rule.order, rule)); + let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher| { + let mut normal_matched: Vec<(u32, usize, usize)> = Vec::new(); + let mut important_matched: Vec<(u32, usize, usize)> = Vec::new(); + if rules.candidate_slot_count != 0 { + matcher.begin_candidate_collection(rules.candidate_slot_count); + } + let mut consider = + |bucket: Option<&Vec>, + normal_matched: &mut Vec<(u32, usize, usize)>, + important_matched: &mut Vec<(u32, usize, usize)>| { + let Some(indices) = bucket else { return }; + for &index in indices { + let rule = &rules.rules[index]; + if rule.candidate_slot != NO_CANDIDATE_SLOT + && !matcher.mark_candidate(rule.candidate_slot as usize) + { + continue; + } + // Candidate buckets only reject impossible originating + // elements. Full selector matching remains authoritative. + // Container lookup is an ancestor walk, so keep it behind + // the selector match as well. + if matcher.matches(tree, nid, &rule.sel) + && self.container_condition_is_active( + rule.container_condition_id, + nid, + ContainerQuerySubjectKind::OriginatingPseudo, + &mut evaluator, + ) + { + let matched = (rule.specificity, rule.order, index); + if !rule.normal_decls.is_empty() { + normal_matched.push(matched); + } + if !rule.important_decls.is_empty() { + important_matched.push(matched); + } + } + } + }; + + if let Some(node) = tree.get_node(nid) { + if let Some(element) = node.as_element() { + consider( + rules.by_local.get(element.local.as_ref()), + &mut normal_matched, + &mut important_matched, + ); + } + if let Some(id) = node.get_attribute("id") { + consider( + rules.by_id.get(id), + &mut normal_matched, + &mut important_matched, + ); + } + if let Some(classes) = node.get_attribute("class") { + for class in classes.split_whitespace() { + consider( + rules.by_class.get(class), + &mut normal_matched, + &mut important_matched, + ); + } + } + if !rules.by_attribute.is_empty() { + if let Some(attributes) = node.attrs() { + for attribute in attributes { + consider( + rules.by_attribute.get(attribute.name.local.as_ref()), + &mut normal_matched, + &mut important_matched, + ); + } + } } } - if matched.is_empty() { + if is_root_element(tree, nid) { + consider( + (!rules.by_root.is_empty()).then_some(&rules.by_root), + &mut normal_matched, + &mut important_matched, + ); + } + consider( + (!rules.universal.is_empty()).then_some(&rules.universal), + &mut normal_matched, + &mut important_matched, + ); + + if normal_matched.is_empty() && important_matched.is_empty() { return None; } - let mut normal_matched = matched.clone(); - normal_matched.sort_unstable_by(|a, b| { - compare_rule_cascade( - a.2.layer.as_ref(), - a.0, - a.1, - b.2.layer.as_ref(), - b.0, - b.1, - false, - ) - }); - let mut important_matched = matched; - important_matched.sort_unstable_by(|a, b| { - compare_rule_cascade( - a.2.layer.as_ref(), - a.0, - a.1, - b.2.layer.as_ref(), - b.0, - b.1, - true, - ) - }); + if normal_matched.len() > 1 { + normal_matched.sort_unstable_by(|a, b| { + compare_rule_cascade( + rules.rules[a.2].layer.as_ref(), + a.0, + a.1, + rules.rules[b.2].layer.as_ref(), + b.0, + b.1, + false, + ) + }); + } + if important_matched.len() > 1 { + important_matched.sort_unstable_by(|a, b| { + compare_rule_cascade( + rules.rules[a.2].layer.as_ref(), + a.0, + a.1, + rules.rules[b.2].layer.as_ref(), + b.0, + b.1, + true, + ) + }); + } // Generated ::before/::after boxes have an inline outer display // by default. LayoutStyle's general default is block because it // primarily represents ordinary DOM boxes, so set the pseudo @@ -983,38 +1257,52 @@ impl Stylesheet { style.color_scheme_dark = host_style.color_scheme_dark; let inherited_color_scheme_dark = host_style.color_scheme_dark; let mut generated_content = None; - for &(_, _, rule) in &normal_matched { - let expanded = substitute_declarations(&rule.normal_decls, props); + for &(_, _, index) in &normal_matched { + let rule = &rules.rules[index]; + if !rule.normal_flags.has_color_scheme { + continue; + } + let expanded = + substitute_declarations(&rule.normal_decls, props, rule.normal_flags.has_var); crate::style::apply_color_scheme_declarations_from( &mut style, &expanded, inherited_color_scheme_dark, ); } - for &(_, _, rule) in &important_matched { - let expanded = substitute_declarations(&rule.important_decls, props); + for &(_, _, index) in &important_matched { + let rule = &rules.rules[index]; + if !rule.important_flags.has_color_scheme { + continue; + } + let expanded = substitute_declarations( + &rule.important_decls, + props, + rule.important_flags.has_var, + ); crate::style::apply_color_scheme_declarations_from( &mut style, &expanded, inherited_color_scheme_dark, ); } - for &(_, _, rule) in &normal_matched { - let expanded = substitute_declarations(&rule.normal_decls, props); - crate::style::apply_declarations_with_locked_color_scheme( - &mut style, - &expanded, - ); + for &(_, _, index) in &normal_matched { + let rule = &rules.rules[index]; + let expanded = + substitute_declarations(&rule.normal_decls, props, rule.normal_flags.has_var); + crate::style::apply_declarations_with_locked_color_scheme(&mut style, &expanded); if let Some(value) = extract_content(&expanded, tree, nid) { generated_content = value; } } - for &(_, _, rule) in &important_matched { - let expanded = substitute_declarations(&rule.important_decls, props); - crate::style::apply_declarations_with_locked_color_scheme( - &mut style, - &expanded, + for &(_, _, index) in &important_matched { + let rule = &rules.rules[index]; + let expanded = substitute_declarations( + &rule.important_decls, + props, + rule.important_flags.has_var, ); + crate::style::apply_declarations_with_locked_color_scheme(&mut style, &expanded); if let Some(value) = extract_content(&expanded, tree, nid) { generated_content = value; } @@ -1040,8 +1328,15 @@ impl Stylesheet { } #[doc(hidden)] - pub fn debug_stats(&self) -> (usize, usize, usize, usize, usize) { - (self.rules.len(), self.by_id.len(), self.by_class.len(), self.by_local.len(), self.universal.len()) + pub fn debug_stats(&self) -> (usize, usize, usize, usize, usize, usize) { + ( + self.rules.len(), + self.by_id.len(), + self.by_class.len(), + self.by_attribute.len(), + self.by_local.len(), + self.universal.len(), + ) } /// Apply every author rule that matches `nid` to `style`, in cascade order @@ -1117,67 +1412,126 @@ impl Stylesheet { inline_css: Option<&str>, mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>, ) -> Option> { - // (specificity, order, rule index) for each matching rule. - let mut matched: Vec<(u32, usize, usize)> = Vec::new(); - let mut consider = |bucket: Option<&Vec>, matched: &mut Vec<(u32, usize, usize)>| { - if let Some(idxs) = bucket { - for &i in idxs { - let rule = &self.rules[i]; - // Container lookup is an ancestor walk. Keep it behind - // selector matching and cache the result per condition. - if matcher.matches(tree, nid, &rule.sel) - && self.container_condition_is_active( - rule.container_condition_id, - nid, - ContainerQuerySubjectKind::Element, - &mut evaluator, - ) - { - matched.push((rule.sel.specificity(), rule.order, i)); + // Keep the two cascade priorities separate from the outset. A typical + // stylesheet has very few important declarations, so cloning and + // sorting every matching normal-only rule into an empty important pass + // is substantial wasted work on every element. + let mut normal_matched: Vec<(u32, usize, usize)> = Vec::new(); + let mut important_matched: Vec<(u32, usize, usize)> = Vec::new(); + if self.candidate_slot_count != 0 { + matcher.begin_candidate_collection(self.candidate_slot_count); + } + let mut consider = + |bucket: Option<&Vec>, + normal_matched: &mut Vec<(u32, usize, usize)>, + important_matched: &mut Vec<(u32, usize, usize)>| { + if let Some(idxs) = bucket { + for &i in idxs { + let rule = &self.rules[i]; + if rule.candidate_slot != NO_CANDIDATE_SLOT + && !matcher.mark_candidate(rule.candidate_slot as usize) + { + continue; + } + // Container lookup is an ancestor walk. Keep it behind + // selector matching and cache the result per condition. + if matcher.matches(tree, nid, &rule.sel) + && self.container_condition_is_active( + rule.container_condition_id, + nid, + ContainerQuerySubjectKind::Element, + &mut evaluator, + ) + { + let matched = (rule.specificity, rule.order, i); + if !rule.normal_decls.is_empty() { + normal_matched.push(matched); + } + if !rule.important_decls.is_empty() { + important_matched.push(matched); + } + } + } + } + }; + + consider( + self.by_local.get(local), + &mut normal_matched, + &mut important_matched, + ); + if is_root_element(tree, nid) { + consider( + (!self.by_root.is_empty()).then_some(&self.by_root), + &mut normal_matched, + &mut important_matched, + ); + } + if let Some(id) = id { + consider( + self.by_id.get(id), + &mut normal_matched, + &mut important_matched, + ); + } + for c in classes { + consider( + self.by_class.get(c), + &mut normal_matched, + &mut important_matched, + ); + } + if !self.by_attribute.is_empty() { + if let Some(node) = tree.get_node(nid) { + if let Some(attributes) = node.attrs() { + for attribute in attributes { + consider( + self.by_attribute.get(attribute.name.local.as_ref()), + &mut normal_matched, + &mut important_matched, + ); } } } - }; - - consider(self.by_local.get(local), &mut matched); - if let Some(id) = id { - consider(self.by_id.get(id), &mut matched); - } - for c in classes { - consider(self.by_class.get(c), &mut matched); } if !self.universal.is_empty() { - consider(Some(&self.universal), &mut matched); + consider( + Some(&self.universal), + &mut normal_matched, + &mut important_matched, + ); } - let mut normal_matched = matched.clone(); - normal_matched.sort_unstable_by(|a, b| { - let left = &self.rules[a.2]; - let right = &self.rules[b.2]; - compare_rule_cascade( - left.layer.as_ref(), - a.0, - a.1, - right.layer.as_ref(), - b.0, - b.1, - false, - ) - }); - let mut important_matched = matched; - important_matched.sort_unstable_by(|a, b| { - let left = &self.rules[a.2]; - let right = &self.rules[b.2]; - compare_rule_cascade( - left.layer.as_ref(), - a.0, - a.1, - right.layer.as_ref(), - b.0, - b.1, - true, - ) - }); + if normal_matched.len() > 1 { + normal_matched.sort_unstable_by(|a, b| { + let left = &self.rules[a.2]; + let right = &self.rules[b.2]; + compare_rule_cascade( + left.layer.as_ref(), + a.0, + a.1, + right.layer.as_ref(), + b.0, + b.1, + false, + ) + }); + } + if important_matched.len() > 1 { + important_matched.sort_unstable_by(|a, b| { + let left = &self.rules[a.2]; + let right = &self.rules[b.2]; + compare_rule_cascade( + left.layer.as_ref(), + a.0, + a.1, + right.layer.as_ref(), + b.0, + b.1, + true, + ) + }); + } let (inline_normal, inline_important) = inline_css .map(crate::style::partition_declarations) @@ -1186,109 +1540,143 @@ impl Stylesheet { // Pass 1: collect this element's own custom properties (`--x: value`), // in cascade order (last wins), layered over the inherited map. Custom // properties cascade fully before any `var()` is substituted. - let mut own: Vec<(String, String)> = Vec::new(); - let mut collect_custom = |css: &str| { - for decl in crate::style::split_declarations(css) { - if let Some((name, val)) = decl.split_once(':') { - let name = name.trim(); - if name.starts_with("--") && name.len() > 2 { - own.push((name.to_string(), val.trim().to_string())); + let inline_normal_flags = declaration_stream_flags(&inline_normal); + let inline_important_flags = declaration_stream_flags(&inline_important); + let has_own_custom_properties = inline_normal_flags.has_custom_properties + || inline_important_flags.has_custom_properties + || normal_matched + .iter() + .any(|&(_, _, i)| self.rules[i].normal_flags.has_custom_properties) + || important_matched + .iter() + .any(|&(_, _, i)| self.rules[i].important_flags.has_custom_properties); + + // Most elements neither declare custom properties nor participate in + // an `@property` registration. Reuse the inherited map directly in + // that case instead of parsing every declaration stream, cloning the + // map, and comparing the clone back to its source. + let effective = if !has_own_custom_properties + && self.registered_custom_properties.is_empty() + { + None + } else { + let mut own: Vec<(String, String)> = Vec::new(); + let mut collect_custom = |css: &str| { + for decl in crate::style::split_declarations(css) { + if let Some((name, val)) = decl.split_once(':') { + let name = name.trim(); + if name.starts_with("--") && name.len() > 2 { + own.push((name.to_string(), val.trim().to_string())); + } } } - } - }; - for &(_, _, i) in &normal_matched { - collect_custom(&self.rules[i].normal_decls); - } - collect_custom(&inline_normal); - for &(_, _, i) in &important_matched { - collect_custom(&self.rules[i].important_decls); - } - collect_custom(&inline_important); - let mut resolved_props = parent_props.clone(); - for (name, registration) in &self.registered_custom_properties { - if registration.inherits && resolved_props.contains_key(name) { - continue; - } - if let Some(initial) = ®istration.initial_value { - resolved_props.insert(name.clone(), initial.clone()); - } else { - resolved_props.remove(name); - } - } - let registration_changed_props = resolved_props != *parent_props; - let has_own = !own.is_empty(); - let mut own_names = std::collections::HashSet::new(); - for (k, v) in own { - own_names.insert(k.clone()); - let registration = self.registered_custom_properties.get(&k); - let set_initial = |props: &mut HashMap| { - if let Some(initial) = registration.and_then(|entry| entry.initial_value.as_ref()) { - props.insert(k.clone(), initial.clone()); - } else { - props.remove(&k); - } }; - let inherit = |props: &mut HashMap| { - if let Some(inherited) = parent_props.get(&k) { - props.insert(k.clone(), inherited.clone()); - } else if let Some(initial) = - registration.and_then(|entry| entry.initial_value.as_ref()) - { - props.insert(k.clone(), initial.clone()); + for &(_, _, i) in &normal_matched { + let rule = &self.rules[i]; + if rule.normal_flags.has_custom_properties { + collect_custom(&rule.normal_decls); + } + } + if inline_normal_flags.has_custom_properties { + collect_custom(&inline_normal); + } + for &(_, _, i) in &important_matched { + let rule = &self.rules[i]; + if rule.important_flags.has_custom_properties { + collect_custom(&rule.important_decls); + } + } + if inline_important_flags.has_custom_properties { + collect_custom(&inline_important); + } + + let mut resolved_props = parent_props.clone(); + for (name, registration) in &self.registered_custom_properties { + if registration.inherits && resolved_props.contains_key(name) { + continue; + } + if let Some(initial) = ®istration.initial_value { + resolved_props.insert(name.clone(), initial.clone()); } else { - props.remove(&k); + resolved_props.remove(name); } - }; - match v.trim().to_ascii_lowercase().as_str() { - "initial" => set_initial(&mut resolved_props), - "inherit" => inherit(&mut resolved_props), - "unset" | "revert" | "revert-layer" - if registration.is_some_and(|entry| !entry.inherits) => - { - set_initial(&mut resolved_props); - } - "unset" | "revert" | "revert-layer" => inherit(&mut resolved_props), - _ => { - let valid = registration.is_none_or(|entry| { - substitute_var_value(&v, &resolved_props, 0) - .is_some_and(|value| registered_value_matches(entry, &value)) - }); - if valid { - resolved_props.insert(k, v); + } + let registration_changed_props = resolved_props != *parent_props; + let has_own = !own.is_empty(); + let mut own_names = std::collections::HashSet::new(); + for (k, v) in own { + own_names.insert(k.clone()); + let registration = self.registered_custom_properties.get(&k); + let set_initial = |props: &mut HashMap| { + if let Some(initial) = + registration.and_then(|entry| entry.initial_value.as_ref()) + { + props.insert(k.clone(), initial.clone()); } else { + props.remove(&k); + } + }; + let inherit = |props: &mut HashMap| { + if let Some(inherited) = parent_props.get(&k) { + props.insert(k.clone(), inherited.clone()); + } else if let Some(initial) = + registration.and_then(|entry| entry.initial_value.as_ref()) + { + props.insert(k.clone(), initial.clone()); + } else { + props.remove(&k); + } + }; + match v.trim().to_ascii_lowercase().as_str() { + "initial" => set_initial(&mut resolved_props), + "inherit" => inherit(&mut resolved_props), + "unset" | "revert" | "revert-layer" + if registration.is_some_and(|entry| !entry.inherits) => + { set_initial(&mut resolved_props); } + "unset" | "revert" | "revert-layer" => inherit(&mut resolved_props), + _ => { + let valid = registration.is_none_or(|entry| { + substitute_var_value(&v, &resolved_props, 0) + .is_some_and(|value| registered_value_matches(entry, &value)) + }); + if valid { + resolved_props.insert(k, v); + } else { + set_initial(&mut resolved_props); + } + } } } - } - // Custom properties inherit their computed value, not their original - // token stream. Resolve only declarations won on this element against - // the complete same-element environment (so forward references work), - // then pass those substituted values to descendants. Re-resolving an - // inherited `--b:var(--a)` after a child overrides `--a` is observably - // wrong: browsers keep the parent's already-computed `--b`. - let resolution_environment = resolved_props.clone(); - for name in own_names { - let Some(value) = resolution_environment.get(&name) else { - continue; - }; - if let Some(computed) = substitute_var_value(value, &resolution_environment, 0) { - resolved_props.insert(name, computed); - } else if let Some(initial) = self - .registered_custom_properties - .get(&name) - .and_then(|entry| entry.initial_value.clone()) - { - resolved_props.insert(name, initial); - } else { - resolved_props.remove(&name); + // Custom properties inherit their computed value, not their original + // token stream. Resolve only declarations won on this element against + // the complete same-element environment (so forward references work), + // then pass those substituted values to descendants. Re-resolving an + // inherited `--b:var(--a)` after a child overrides `--a` is observably + // wrong: browsers keep the parent's already-computed `--b`. + let resolution_environment = resolved_props.clone(); + for name in own_names { + let Some(value) = resolution_environment.get(&name) else { + continue; + }; + if let Some(computed) = substitute_var_value(value, &resolution_environment, 0) { + resolved_props.insert(name, computed); + } else if let Some(initial) = self + .registered_custom_properties + .get(&name) + .and_then(|entry| entry.initial_value.clone()) + { + resolved_props.insert(name, initial); + } else { + resolved_props.remove(&name); + } + } + if has_own || registration_changed_props { + Some(resolved_props) + } else { + None } - } - let effective = if has_own || registration_changed_props { - Some(resolved_props) - } else { - None }; let props = effective.as_ref().unwrap_or(parent_props); @@ -1298,46 +1686,59 @@ impl Stylesheet { // across the complete author cascade before applying any color-valued // property. The style starts with its inherited scheme. for &(_, _, i) in &normal_matched { + let rule = &self.rules[i]; + if !rule.normal_flags.has_color_scheme { + continue; + } let expanded = - substitute_declarations(&self.rules[i].normal_decls, props); + substitute_declarations(&rule.normal_decls, props, rule.normal_flags.has_var); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + } + if inline_normal_flags.has_color_scheme { + let expanded = + substitute_declarations(&inline_normal, props, inline_normal_flags.has_var); crate::style::apply_color_scheme_declarations_from( style, &expanded, inherited_color_scheme_dark, ); } - let expanded = substitute_declarations(&inline_normal, props); - crate::style::apply_color_scheme_declarations_from( - style, - &expanded, - inherited_color_scheme_dark, - ); for &(_, _, i) in &important_matched { + let rule = &self.rules[i]; + if !rule.important_flags.has_color_scheme { + continue; + } let expanded = - substitute_declarations(&self.rules[i].important_decls, props); + substitute_declarations(&rule.important_decls, props, rule.important_flags.has_var); + crate::style::apply_color_scheme_declarations_from( + style, + &expanded, + inherited_color_scheme_dark, + ); + } + if inline_important_flags.has_color_scheme { + let expanded = + substitute_declarations(&inline_important, props, inline_important_flags.has_var); crate::style::apply_color_scheme_declarations_from( style, &expanded, inherited_color_scheme_dark, ); } - let expanded = substitute_declarations(&inline_important, props); - crate::style::apply_color_scheme_declarations_from( - style, - &expanded, - inherited_color_scheme_dark, - ); // Pass 2: apply normal declarations with `var()` substituted against // the resolved custom-property map. for &(_, _, i) in &normal_matched { - let expanded = substitute_declarations(&self.rules[i].normal_decls, props); - crate::style::apply_declarations_with_locked_color_scheme( - style, - &expanded, - ); + let rule = &self.rules[i]; + let expanded = + substitute_declarations(&rule.normal_decls, props, rule.normal_flags.has_var); + crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); } - let expanded = substitute_declarations(&inline_normal, props); + let expanded = substitute_declarations(&inline_normal, props, inline_normal_flags.has_var); crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); // Animation control properties from author !important participate in @@ -1345,33 +1746,54 @@ impl Stylesheet { // important author origin. Resolve those controls on a temporary style // before sampling, then let the ordinary important pass override the // sampled values where appropriate. - let mut animation_style = style.clone(); - for &(_, _, i) in &important_matched { - let expanded = substitute_declarations(&self.rules[i].important_decls, props); - crate::style::apply_animation_declarations(&mut animation_style, &expanded); - } - let expanded = substitute_declarations(&inline_important, props); - crate::style::apply_animation_declarations(&mut animation_style, &expanded); - if let Some(name) = animation_style.animation_name.as_deref() { - if let Some(keyframes) = self.keyframes.get(name) { - sample_animation_properties( - keyframes, - style, - &animation_style.animation_timing, - self.animation_sample_time, + let important_has_animation = inline_important_flags.has_animation + || important_matched + .iter() + .any(|&(_, _, i)| self.rules[i].important_flags.has_animation); + if !self.keyframes.is_empty() && (style.animation_name.is_some() || important_has_animation) + { + let mut animation_style = style.clone(); + for &(_, _, i) in &important_matched { + let rule = &self.rules[i]; + if !rule.important_flags.has_animation { + continue; + } + let expanded = substitute_declarations( + &rule.important_decls, props, + rule.important_flags.has_var, ); + crate::style::apply_animation_declarations(&mut animation_style, &expanded); + } + if inline_important_flags.has_animation { + let expanded = substitute_declarations( + &inline_important, + props, + inline_important_flags.has_var, + ); + crate::style::apply_animation_declarations(&mut animation_style, &expanded); + } + if let Some(name) = animation_style.animation_name.as_deref() { + if let Some(keyframes) = self.keyframes.get(name) { + sample_animation_properties( + keyframes, + style, + &animation_style.animation_timing, + self.animation_sample_time, + props, + ); + } } } for &(_, _, i) in &important_matched { - let expanded = substitute_declarations(&self.rules[i].important_decls, props); - crate::style::apply_declarations_with_locked_color_scheme( - style, - &expanded, - ); + let rule = &self.rules[i]; + let expanded = + substitute_declarations(&rule.important_decls, props, rule.important_flags.has_var); + crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); } - let expanded = substitute_declarations(&inline_important, props); + let expanded = + substitute_declarations(&inline_important, props, inline_important_flags.has_var); crate::style::apply_declarations_with_locked_color_scheme(style, &expanded); effective } @@ -1408,12 +1830,13 @@ fn compile_keyframe_body(css: &str) -> Keyframes { let (normal, _) = crate::style::partition_declarations(&stop.declarations); let mut declarations = HashMap::::new(); for raw in crate::style::split_declarations(&normal) { - let Some((name, value)) = raw.trim().split_once(':') else { continue }; + let Some((name, value)) = raw.trim().split_once(':') else { + continue; + }; let name = name.trim().to_ascii_lowercase(); let value = value.trim(); if value.is_empty() - || (!value.contains("var(") - && !supports_animation_declaration(&name, value)) + || (!value.contains("var(") && !supports_animation_declaration(&name, value)) { continue; } @@ -1625,11 +2048,7 @@ fn sample_property_track( continue; } let mut endpoint = underlying.clone(); - crate::style::apply_animation_property_value( - &mut endpoint, - &stop.declaration.name, - &value, - ); + crate::style::apply_animation_property_value(&mut endpoint, &stop.declaration.name, &value); if let Some(value) = animation_value_from_style(property, &endpoint) { resolved.push((stop.offset, value)); } @@ -1668,10 +2087,9 @@ fn animation_value_from_style( match property { Transform => Some(AnimationValue::Transform(style.transform_ops.clone())), Translate => { - let (x, y) = style.individual_translate.unwrap_or(( - crate::Dimension::Px(0.0), - crate::Dimension::Px(0.0), - )); + let (x, y) = style + .individual_translate + .unwrap_or((crate::Dimension::Px(0.0), crate::Dimension::Px(0.0))); Some(AnimationValue::Translate( crate::TransformLength { value: x, @@ -1719,12 +2137,14 @@ fn animation_value_from_style( } Top | Right | Bottom | Left => { let index = physical_side_index(property); - Some(AnimationValue::Length(match &style.inset_expressions[index] { - Some(expression) => AnimatedLength::Expression(expression.clone()), - None => style.inset[index] - .map(AnimatedLength::Dimension) - .unwrap_or(AnimatedLength::Auto), - })) + Some(AnimationValue::Length( + match &style.inset_expressions[index] { + Some(expression) => AnimatedLength::Expression(expression.clone()), + None => style.inset[index] + .map(AnimatedLength::Dimension) + .unwrap_or(AnimatedLength::Auto), + }, + )) } MarginTop | MarginRight | MarginBottom | MarginLeft => { let index = physical_side_index(property); @@ -1753,9 +2173,7 @@ fn animation_value_from_style( Opacity => Some(AnimationValue::Number( style.opacity.unwrap_or(1.0).clamp(0.0, 1.0), )), - Color => Some(AnimationValue::Color( - style.color.unwrap_or([0, 0, 0, 255]), - )), + Color => Some(AnimationValue::Color(style.color.unwrap_or([0, 0, 0, 255]))), BackgroundColor => Some(AnimationValue::Color( style.background_color.unwrap_or([0, 0, 0, 0]), )), @@ -1809,7 +2227,10 @@ fn apply_animation_value( style.individual_scale = Some((x, y)); set_animation_containing_block_trigger(style, crate::CB_TRIGGER_SCALE, true); } - (Width | Height | MinWidth | MinHeight | MaxWidth | MaxHeight, AnimationValue::Length(value)) => { + ( + Width | Height | MinWidth | MinHeight | MaxWidth | MaxHeight, + AnimationValue::Length(value), + ) => { set_size_animation_value(style, property, value); } (Top | Right | Bottom | Left, AnimationValue::Length(value)) => { @@ -1820,7 +2241,10 @@ fn apply_animation_value( let index = physical_side_index(property); set_margin_animation_value(style, index, value); } - (PaddingTop | PaddingRight | PaddingBottom | PaddingLeft, AnimationValue::Length(value)) => { + ( + PaddingTop | PaddingRight | PaddingBottom | PaddingLeft, + AnimationValue::Length(value), + ) => { let index = physical_side_index(property); set_padding_animation_value(style, index, value); } @@ -1834,9 +2258,7 @@ fn apply_animation_value( style.opacity = Some(value.clamp(0.0, 1.0)); } (Color, AnimationValue::Color(value)) => style.color = Some(value), - (BackgroundColor, AnimationValue::Color(value)) => { - style.background_color = Some(value) - } + (BackgroundColor, AnimationValue::Color(value)) => style.background_color = Some(value), (BorderTopColor, AnimationValue::Color(value)) => { style.border_model.colors.top = Some(value) } @@ -1858,11 +2280,10 @@ fn apply_animation_value( _ => return, } let colors = style.border_model.colors; - style.border_color = (colors.top == colors.right - && colors.top == colors.bottom - && colors.top == colors.left) - .then_some(colors.top) - .flatten(); + style.border_color = + (colors.top == colors.right && colors.top == colors.bottom && colors.top == colors.left) + .then_some(colors.top) + .flatten(); } fn interpolate_animation_value( @@ -1901,10 +2322,9 @@ fn interpolate_animation_value( from_y + (to_y - from_y) * position, ) } - ( - AnimationValue::BackgroundPosition(from), - AnimationValue::BackgroundPosition(to), - ) => AnimationValue::BackgroundPosition(from.interpolate(*to, position)), + (AnimationValue::BackgroundPosition(from), AnimationValue::BackgroundPosition(to)) => { + AnimationValue::BackgroundPosition(from.interpolate(*to, position)) + } (AnimationValue::Visibility(from), AnimationValue::Visibility(to)) => { // visibility has a special discrete interpolation: if either end // is visible, every interior value is visible. @@ -2080,11 +2500,7 @@ fn set_padding_animation_value(style: &mut LayoutStyle, index: usize, value: Ani } } -fn set_gap_animation_value( - style: &mut LayoutStyle, - row: bool, - value: AnimatedLength, -) { +fn set_gap_animation_value(style: &mut LayoutStyle, row: bool, value: AnimatedLength) { let (slot, expression) = if row { (&mut style.row_gap, &mut style.row_gap_expression) } else { @@ -2195,11 +2611,10 @@ fn interpolate_transform_length( }), (Some(from_expression), Some(to_expression)) if from_expression == to_expression => { Some(crate::TransformLength { - value: interpolate_dimension(from.value, to.value, position) - .unwrap_or(from.value), + value: interpolate_dimension(from.value, to.value, position).unwrap_or(from.value), expression: Some(from_expression.clone()), }) - }, + } _ => None, } } @@ -2238,10 +2653,9 @@ fn interpolate_transform_list( fn identity_transform_operation(operation: &crate::TransformOp) -> crate::TransformOp { match operation { - crate::TransformOp::Translate(x, y) => crate::TransformOp::Translate( - zero_transform_length(x), - zero_transform_length(y), - ), + crate::TransformOp::Translate(x, y) => { + crate::TransformOp::Translate(zero_transform_length(x), zero_transform_length(y)) + } crate::TransformOp::Scale(_, _) => crate::TransformOp::Scale(1.0, 1.0), crate::TransformOp::Rotate(_) => crate::TransformOp::Rotate(0.0), crate::TransformOp::Skew(_, _) => crate::TransformOp::Skew(0.0, 0.0), @@ -2273,12 +2687,13 @@ fn interpolate_transform_operation( ) -> Option { let lerp = |from: f32, to: f32| from + (to - from) * position; Some(match (from, to) { - (crate::TransformOp::Translate(from_x, from_y), crate::TransformOp::Translate(to_x, to_y)) => { - crate::TransformOp::Translate( - interpolate_transform_length(from_x, to_x, position)?, - interpolate_transform_length(from_y, to_y, position)?, - ) - } + ( + crate::TransformOp::Translate(from_x, from_y), + crate::TransformOp::Translate(to_x, to_y), + ) => crate::TransformOp::Translate( + interpolate_transform_length(from_x, to_x, position)?, + interpolate_transform_length(from_y, to_y, position)?, + ), (crate::TransformOp::Scale(from_x, from_y), crate::TransformOp::Scale(to_x, to_y)) => { crate::TransformOp::Scale(lerp(*from_x, *to_x), lerp(*from_y, *to_y)) } @@ -2302,11 +2717,7 @@ fn interpolate_transform_operation( }) } -fn set_animation_containing_block_trigger( - style: &mut LayoutStyle, - trigger: u16, - enabled: bool, -) { +fn set_animation_containing_block_trigger(style: &mut LayoutStyle, trigger: u16, enabled: bool) { if enabled { style.containing_block_triggers |= trigger; } else { @@ -2366,7 +2777,11 @@ fn animation_directed_progress( }; let overall_progress = if duration == 0.0 { - if phase == AnimationPhase::Before { 0.0 } else { iterations } + if phase == AnimationPhase::Before { + 0.0 + } else { + iterations + } } else { active_time / duration }; @@ -2378,10 +2793,7 @@ fn animation_directed_progress( } let mut current_iteration = overall_progress.floor().max(0.0); let mut simple_progress = overall_progress.rem_euclid(1.0); - if phase == AnimationPhase::After - && iterations > 0.0 - && simple_progress == 0.0 - { + if phase == AnimationPhase::After && iterations > 0.0 && simple_progress == 0.0 { simple_progress = 1.0; current_iteration = (current_iteration - 1.0).max(0.0); } @@ -2443,7 +2855,17 @@ fn normalized_keyframe_offsets(stops: &[KeyframeStop]) -> Vec<(f32, &KeyframeSto /// Resolve variables one declaration at a time. An invalid variable poisons /// its entire declaration at computed-value time, but must not erase unrelated /// declarations in the same rule. -fn substitute_declarations(css: &str, props: &HashMap) -> String { +fn substitute_declarations<'a>( + css: &'a str, + props: &HashMap, + has_var: bool, +) -> Cow<'a, str> { + // Partitioned rule streams are already normalized declaration blocks. If + // no value contains var(), applying them directly is both equivalent and + // avoids a String plus one split/serialize pass for every matched rule. + if !has_var { + return Cow::Borrowed(css); + } let mut expanded = String::new(); for declaration in crate::style::split_declarations(css) { let Some((name, value)) = declaration.split_once(':') else { @@ -2457,7 +2879,7 @@ fn substitute_declarations(css: &str, props: &HashMap) -> String expanded.push_str(&value); expanded.push(';'); } - expanded + Cow::Owned(expanded) } /// Substitute every `var(--name, fallback?)` in one property value. `None` @@ -2553,8 +2975,7 @@ fn css_substitution_boundary_merges(left: char, right: char) -> bool { return false; } (name(left) && name(right)) - || ((left.is_ascii_digit() || left == '.') - && (right == '.' || right == '%' || name(right))) + || ((left.is_ascii_digit() || left == '.') && (right == '.' || right == '%' || name(right))) || (matches!(left, '#' | '@') && name(right)) || (!left.is_ascii_digit() && name(left) && right == '(') || (left == '+' && (right.is_ascii_digit() || right == '.')) @@ -2593,7 +3014,9 @@ fn extract_content( ) -> Option>> { let mut result = None; for raw in crate::style::split_declarations(decls) { - let Some((name, value)) = raw.split_once(':') else { continue }; + let Some((name, value)) = raw.split_once(':') else { + continue; + }; if !name.trim().eq_ignore_ascii_case("content") { continue; } @@ -2953,16 +3376,16 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> { parse_stylesheet_for_viewport(css, (1280.0, 720.0)) } -fn parse_stylesheet_for_viewport( - css: &str, - viewport: (f32, f32), -) -> Vec<(String, String)> { +fn parse_stylesheet_for_viewport(css: &str, viewport: (f32, f32)) -> Vec<(String, String)> { let mut conditions = vec![ContainerConditionNode { parent: ContainerConditionId::NONE, alternatives: Vec::new(), }]; parse_stylesheet_for_viewport_preserving_containers( - css, viewport, &mut conditions, ContainerConditionId::NONE, + css, + viewport, + &mut conditions, + ContainerConditionId::NONE, ) .into_iter() // This legacy tuple API cannot express conditional context. Keep its @@ -3041,9 +3464,15 @@ fn parse_stylesheet_for_viewport_preserving_containers_in_layer( let decls = current_decls.trim(); if let Some(at) = sel.strip_prefix('@') { flush_at_rule( - at, sel, decls, &mut rules, viewport, - container_conditions, container_condition_id, - layers, current_layer.as_ref(), + at, + sel, + decls, + &mut rules, + viewport, + container_conditions, + container_condition_id, + layers, + current_layer.as_ref(), ); } else { // The body may contain nested rules (CSS Nesting, ubiquitous @@ -3051,9 +3480,14 @@ fn parse_stylesheet_for_viewport_preserving_containers_in_layer( // Flatten them against this selector; denest also handles the // no-nesting case (just emits the rule's own declarations). denest( - sel, decls, &mut rules, viewport, - container_conditions, container_condition_id, - layers, current_layer.as_ref(), + sel, + decls, + &mut rules, + viewport, + container_conditions, + container_condition_id, + layers, + current_layer.as_ref(), ); } current_selector.clear(); @@ -3095,25 +3529,29 @@ fn flush_at_rule( ) { if let Some(prelude) = at_rule_prelude(at, "media") { if media_query_applies_for_viewport(prelude, viewport) { - rules.extend(parse_stylesheet_for_viewport_preserving_containers_in_layer( - inner, - viewport, - container_conditions, - container_condition_id, - layers, - current_layer.cloned(), - )); + rules.extend( + parse_stylesheet_for_viewport_preserving_containers_in_layer( + inner, + viewport, + container_conditions, + container_condition_id, + layers, + current_layer.cloned(), + ), + ); } } else if let Some(prelude) = at_rule_prelude(at, "supports") { if supports_condition_applies(prelude) { - rules.extend(parse_stylesheet_for_viewport_preserving_containers_in_layer( - inner, - viewport, - container_conditions, - container_condition_id, - layers, - current_layer.cloned(), - )); + rules.extend( + parse_stylesheet_for_viewport_preserving_containers_in_layer( + inner, + viewport, + container_conditions, + container_condition_id, + layers, + current_layer.cloned(), + ), + ); } } else if let Some(prelude) = at_rule_prelude(at, "container") { if let Some(alternatives) = parse_container_query_list(prelude) { @@ -3125,14 +3563,16 @@ fn flush_at_rule( parent: container_condition_id, alternatives, }); - rules.extend(parse_stylesheet_for_viewport_preserving_containers_in_layer( - inner, - viewport, - container_conditions, - id, - layers, - current_layer.cloned(), - )); + rules.extend( + parse_stylesheet_for_viewport_preserving_containers_in_layer( + inner, + viewport, + container_conditions, + id, + layers, + current_layer.cloned(), + ), + ); } } else if let Some((name, prefixed)) = at_rule_prelude(at, "keyframes") .map(|name| (name, false)) @@ -3174,14 +3614,16 @@ fn flush_at_rule( } else { return; }; - rules.extend(parse_stylesheet_for_viewport_preserving_containers_in_layer( - inner, - viewport, - container_conditions, - container_condition_id, - layers, - Some(layer), - )); + rules.extend( + parse_stylesheet_for_viewport_preserving_containers_in_layer( + inner, + viewport, + container_conditions, + container_condition_id, + layers, + Some(layer), + ), + ); } // Other at-rules (@font-face, @import, ...) carry no // layout-relevant rules for us, so drop them. @@ -3192,7 +3634,9 @@ fn parse_property_registration(descriptors: &str) -> Option { @@ -3255,13 +3699,17 @@ fn registered_value_matches(registration: &RegisteredCustomProperty, value: &str "" => value.parse::().ok().is_some_and(f32::is_finite), "" => crate::style::parse_color(value).is_some(), "" => { - if value.parse::().ok().is_some_and(|number| number == 0.0) { + if value + .parse::() + .ok() + .is_some_and(|number| number == 0.0) + { return true; } let lower = value.to_ascii_lowercase(); [ - "rem", "em", "ex", "vmin", "vmax", "dvw", "svw", "lvw", "dvh", - "svh", "lvh", "vw", "vh", "px", "pt", + "rem", "em", "ex", "vmin", "vmax", "dvw", "svw", "lvw", "dvh", "svh", "lvh", "vw", + "vh", "px", "pt", ] .iter() .any(|unit| { @@ -3432,7 +3880,8 @@ fn strip_ascii_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { return None; } let rest = &input[keyword.len()..]; - rest.chars().next() + rest.chars() + .next() .filter(|character| character.is_whitespace()) .map(|_| rest.trim_start()) } @@ -3453,12 +3902,24 @@ fn parse_container_size_feature(input: &str) -> Option { "min-height" => (ContainerQueryComparison::Min, ContainerQueryAxis::Height), "max-height" => (ContainerQueryComparison::Max, ContainerQueryAxis::Height), "height" => (ContainerQueryComparison::Equal, ContainerQueryAxis::Height), - "min-inline-size" => (ContainerQueryComparison::Min, ContainerQueryAxis::InlineSize), - "max-inline-size" => (ContainerQueryComparison::Max, ContainerQueryAxis::InlineSize), - "inline-size" => (ContainerQueryComparison::Equal, ContainerQueryAxis::InlineSize), + "min-inline-size" => ( + ContainerQueryComparison::Min, + ContainerQueryAxis::InlineSize, + ), + "max-inline-size" => ( + ContainerQueryComparison::Max, + ContainerQueryAxis::InlineSize, + ), + "inline-size" => ( + ContainerQueryComparison::Equal, + ContainerQueryAxis::InlineSize, + ), "min-block-size" => (ContainerQueryComparison::Min, ContainerQueryAxis::BlockSize), "max-block-size" => (ContainerQueryComparison::Max, ContainerQueryAxis::BlockSize), - "block-size" => (ContainerQueryComparison::Equal, ContainerQueryAxis::BlockSize), + "block-size" => ( + ContainerQueryComparison::Equal, + ContainerQueryAxis::BlockSize, + ), _ => return None, }; return Some(ContainerQueryExpr::Feature(ContainerSizeFeature { @@ -3499,10 +3960,10 @@ fn parse_container_size_feature(input: &str) -> Option { // `10px < width <= 20px` or the fully reversed equivalent. // Equality is valid only in a single comparison, and a mixed // direction such as `10px < width > 20px` is not a range. - let forward = matches!(*lower_operator, "<" | "<=") - && matches!(*upper_operator, "<" | "<="); - let reverse = matches!(*lower_operator, ">" | ">=") - && matches!(*upper_operator, ">" | ">="); + let forward = + matches!(*lower_operator, "<" | "<=") && matches!(*upper_operator, "<" | "<="); + let reverse = + matches!(*lower_operator, ">" | ">=") && matches!(*upper_operator, ">" | ">="); if !forward && !reverse { return None; } @@ -3564,9 +4025,7 @@ fn split_container_range(input: &str) -> Option<(Vec<&str>, Vec<&str>)> { fn parse_container_query_length(input: &str) -> Option { let input = input.trim().to_ascii_lowercase(); - let number = |value: &str| { - value.parse::().ok().filter(|value| value.is_finite()) - }; + let number = |value: &str| value.parse::().ok().filter(|value| value.is_finite()); if let Some(value) = input.strip_suffix("rem").and_then(number) { return Some(ContainerQueryLength::Rem(value)); } @@ -3576,7 +4035,9 @@ fn parse_container_query_length(input: &str) -> Option { if let Some(value) = input.strip_suffix("px").and_then(number) { return Some(ContainerQueryLength::Px(value)); } - number(&input).filter(|value| *value == 0.0).map(ContainerQueryLength::Px) + number(&input) + .filter(|value| *value == 0.0) + .map(ContainerQueryLength::Px) } /// Evaluate the boolean subset of CSS Conditional Rules used by modern @@ -3785,10 +4246,7 @@ fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option bool { +pub(crate) fn media_query_applies_for_viewport(query: &str, viewport: (f32, f32)) -> bool { // A media-query list is an OR, not an AND. Evaluate each top-level comma // arm independently (commas inside functions such as rgb() / calc() are // not list separators). This also keeps an inapplicable `print` arm from @@ -3798,10 +4256,7 @@ pub(crate) fn media_query_applies_for_viewport( .any(|query| single_media_query_applies_for_viewport(query, viewport)) } -fn single_media_query_applies_for_viewport( - query: &str, - viewport: (f32, f32), -) -> bool { +fn single_media_query_applies_for_viewport(query: &str, viewport: (f32, f32)) -> bool { let viewport_w = viewport.0; let viewport_h = viewport.1; let query = query.trim().strip_prefix("@media").unwrap_or(query).trim(); @@ -4065,7 +4520,9 @@ fn denest( } j += 1; } - let inner: String = chars[i + 1..j.saturating_sub(1).max(i + 1)].iter().collect(); + let inner: String = chars[i + 1..j.saturating_sub(1).max(i + 1)] + .iter() + .collect(); let pre = prelude.trim(); if let Some(at) = pre.strip_prefix('@') { // A nested at-rule keeps the enclosing selector for its body. @@ -4255,12 +4712,7 @@ enum LengthAxis { /// computed font. Modern utility frameworks deliberately use those units for /// breakpoints, so treating only `px` as typed made every `min-width:64rem` /// desktop rule unconditional. -fn extract_length( - s: &str, - prop: &str, - viewport: (f32, f32), - axis: LengthAxis, -) -> Option { +fn extract_length(s: &str, prop: &str, viewport: (f32, f32), axis: LengthAxis) -> Option { let start = s.find(prop)? + prop.len(); let rest = &s[start..]; if let Some(inner) = rest.strip_prefix("calc(") { @@ -4289,11 +4741,7 @@ fn extract_length_before( parse_length_prefix(value, viewport, axis) } -fn parse_length_prefix( - input: &str, - viewport: (f32, f32), - axis: LengthAxis, -) -> Option { +fn parse_length_prefix(input: &str, viewport: (f32, f32), axis: LengthAxis) -> Option { let numeric_len = input .char_indices() .take_while(|(_, c)| c.is_ascii_digit() || matches!(c, '.' | '+' | '-')) @@ -4330,11 +4778,7 @@ fn parse_length_prefix( } /// Sum the common media-query `calc()` form (`64rem - 1px`) left to right. -fn eval_length_sum( - expr: &str, - viewport: (f32, f32), - axis: LengthAxis, -) -> Option { +fn eval_length_sum(expr: &str, viewport: (f32, f32), axis: LengthAxis) -> Option { let mut total = 0.0; let mut sign = 1.0; let mut term = String::new(); @@ -4426,6 +4870,254 @@ mod tests { (style, effective, before) } + #[test] + #[ignore = "release-only cascade microbenchmark"] + fn benchmark_sparse_cascade_hot_path() { + use std::hint::black_box; + use std::time::Instant; + + const RULE_COUNT: usize = 96; + const ITERATIONS: usize = 6_000; + + let tree = obscura_dom::parse_html(r#"
"#); + let target = tree.get_element_by_id("target").unwrap(); + let css = (0..RULE_COUNT) + .map(|index| { + format!( + ".target {{ width:{}px; margin-left:{}px; color:rgb({}, {}, {}) }}", + index + 1, + index % 17, + index % 255, + (index * 3) % 255, + (index * 7) % 255, + ) + }) + .collect::(); + let sheet = Stylesheet::parse(&tree, &[css]); + let mut matcher = tree.matcher(); + let classes = vec!["target".to_string()]; + let parent_props = (0..32) + .map(|index| (format!("--inherited-{index}"), format!("{}px", index + 1))) + .collect::>(); + + let started = Instant::now(); + for _ in 0..ITERATIONS { + let mut style = LayoutStyle::default(); + let effective = sheet.apply( + &tree, + &mut matcher, + target, + Some("target"), + &classes, + "div", + &mut style, + &parent_props, + None, + ); + black_box((&style, effective)); + } + let elapsed = started.elapsed(); + eprintln!( + "sparse cascade: {ITERATIONS} iterations, {} ns/apply ({elapsed:?})", + elapsed.as_nanos() / ITERATIONS as u128, + ); + } + + #[test] + fn declaration_stream_flags_guard_hot_passes_and_borrow_plain_rules() { + let plain = "width:12px;color:red;"; + let plain_flags = declaration_stream_flags(plain); + assert_eq!(plain_flags, DeclarationStreamFlags::default()); + assert!(matches!( + substitute_declarations(plain, &HashMap::new(), plain_flags.has_var), + Cow::Borrowed(value) if value == plain + )); + + let dynamic = "--tone:dark;color-scheme:light dark;animation-name:pulse;color:var(--tone);"; + let flags = declaration_stream_flags(dynamic); + assert!(flags.has_custom_properties); + assert!(flags.has_var); + assert!(flags.has_color_scheme); + assert!(flags.has_animation); + let props = HashMap::from([("--tone".to_string(), "rebeccapurple".to_string())]); + let expanded = substitute_declarations(dynamic, &props, flags.has_var); + assert!(matches!(&expanded, Cow::Owned(_))); + assert!(expanded.contains("color:rebeccapurple;")); + + let similarly_named = declaration_stream_flags( + "my-color-scheme:dark;animationish:fade;background:variety(red);", + ); + assert!(!similarly_named.has_var); + assert!(!similarly_named.has_color_scheme); + assert!(!similarly_named.has_animation); + } + + #[test] + fn root_rules_use_the_document_element_bucket_without_losing_is_arms() { + let tree = obscura_dom::parse_html( + r#"
"#, + ); + let sheet = Stylesheet::parse( + &tree, + &[r#" + :root { width:321px } + :is(:root, .card) { height:123px } + "# + .to_string()], + ); + let (_, _, class_keys, _, _, universal) = sheet.debug_stats(); + assert_eq!(class_keys, 1); + assert_eq!( + universal, 0, + ":root must not remain in the universal bucket" + ); + + let root = tree.query_selector("html").unwrap().unwrap(); + let body = tree.query_selector("body").unwrap().unwrap(); + let card = tree.get_element_by_id("card").unwrap(); + let mut matcher = tree.matcher(); + let mut root_style = LayoutStyle::default(); + sheet.apply( + &tree, + &mut matcher, + root, + None, + &[], + "html", + &mut root_style, + &HashMap::new(), + None, + ); + assert_eq!(root_style.width, crate::Dimension::Px(321.0)); + assert_eq!(root_style.height, crate::Dimension::Px(123.0)); + + let mut body_style = LayoutStyle::default(); + sheet.apply( + &tree, + &mut matcher, + body, + None, + &[], + "body", + &mut body_style, + &HashMap::new(), + None, + ); + assert_ne!(body_style.height, crate::Dimension::Px(123.0)); + + let mut card_style = LayoutStyle::default(); + sheet.apply( + &tree, + &mut matcher, + card, + Some("card"), + &["card".to_string()], + "div", + &mut card_style, + &HashMap::new(), + None, + ); + assert_eq!(card_style.height, crate::Dimension::Px(123.0)); + } + + #[test] + fn pseudo_rules_use_subject_buckets_and_dense_dedup() { + let tree = obscura_dom::parse_html( + r#"
+
"#, + ); + let sheet = Stylesheet::parse( + &tree, + &[r#" + :is(.alpha, [data-kind])::before { content:"indexed" } + .missing::before { content:"wrong" } + "# + .to_string()], + ); + + assert_eq!(sheet.before_rules.rules.len(), 2); + assert_eq!(sheet.before_rules.by_class.get("alpha").unwrap(), &[0]); + assert_eq!( + sheet.before_rules.by_attribute.get("data-kind").unwrap(), + &[0] + ); + assert_eq!(sheet.before_rules.rules[0].candidate_slot, 0); + assert_eq!(sheet.before_rules.candidate_slot_count, 1); + + for id in ["both", "attribute"] { + let target = tree.get_element_by_id(id).unwrap(); + let before = sheet + .pseudo_styles( + &tree, + &mut tree.matcher(), + target, + &HashMap::new(), + &LayoutStyle::default(), + ) + .0 + .expect("the indexed pseudo selector should match"); + assert_eq!(before.before_content.as_deref(), Some("indexed")); + } + } + + #[test] + fn pseudo_candidate_bucket_does_not_replace_full_selector_matching() { + let tree = obscura_dom::parse_html(r#"
"#); + let target = tree.get_element_by_id("target").unwrap(); + let sheet = Stylesheet::parse( + &tree, + &[r#" + .alpha::before { content:"base" } + .alpha:not(.blocked)::before { content:"wrong" } + "# + .to_string()], + ); + let before = sheet + .pseudo_styles( + &tree, + &mut tree.matcher(), + target, + &HashMap::new(), + &LayoutStyle::default(), + ) + .0 + .expect("the base pseudo selector should match"); + assert_eq!(before.before_content.as_deref(), Some("base")); + } + + #[test] + fn disjoint_functional_subjects_use_all_buckets_and_one_dense_slot() { + let tree = obscura_dom::parse_html( + r#"
"#, + ); + let target = tree.get_element_by_id("target").unwrap(); + let sheet = Stylesheet::parse( + &tree, + &[":is(.alpha, [data-kind]) { width:47px }".to_string()], + ); + + assert!(sheet.universal.is_empty()); + assert_eq!(sheet.by_class.get("alpha").unwrap(), &[0]); + assert_eq!(sheet.by_attribute.get("data-kind").unwrap(), &[0]); + assert_eq!(sheet.rules[0].candidate_slot, 0); + assert_eq!(sheet.candidate_slot_count, 1); + + let mut matcher = tree.matcher(); + let mut style = LayoutStyle::default(); + sheet.apply( + &tree, + &mut matcher, + target, + Some("target"), + &["alpha".to_string()], + "div", + &mut style, + &HashMap::new(), + None, + ); + assert_eq!(style.width, crate::Dimension::Px(47.0)); + } + #[test] fn layer_order_statement_beats_block_source_order() { let (style, _, _) = cascade_layer_target( @@ -4459,17 +5151,13 @@ mod tests { @layer reset { .target { width:100px !important } } #target { width:300px !important } "#; - let (style, _, _) = cascade_layer_target( - &[css], - Some("width:500px"), - ); + let (style, _, _) = cascade_layer_target(&[css], Some("width:500px")); assert_eq!( style.width, crate::Dimension::Px(100.0), "the earliest layered important declaration beats later layers, unlayered author rules, and normal inline style" ); - let (inline_important, _, _) = - cascade_layer_target(&[css], Some("width:500px !important")); + let (inline_important, _, _) = cascade_layer_target(&[css], Some("width:500px !important")); assert_eq!( inline_important.width, crate::Dimension::Px(500.0), @@ -4477,6 +5165,63 @@ mod tests { ); } + #[test] + fn sparse_priority_streams_preserve_normal_and_important_order() { + let (style, _, _) = cascade_layer_target( + &[r#" + @layer first, second; + @layer second { + #target { width:20px } + #target { height:40px !important } + } + @layer first { + .target { width:10px } + .target { height:30px !important } + } + .target { width:50px } + "#], + None, + ); + assert_eq!(style.width, crate::Dimension::Px(50.0)); + assert_eq!(style.height, crate::Dimension::Px(30.0)); + } + + #[test] + fn inherited_custom_property_fast_path_reuses_parent_map() { + let tree = obscura_dom::parse_html(r#"
"#); + let target = tree.get_element_by_id("target").unwrap(); + let sheet = Stylesheet::parse( + &tree, + &[r#" + .target { + width:var(--inherited-size); + background-image:url("/asset--content-hash.png"); + } + "# + .to_string()], + ); + let parent_props = HashMap::from([("--inherited-size".to_string(), "37px".to_string())]); + let mut matcher = tree.matcher(); + let mut style = LayoutStyle::default(); + let effective = sheet.apply( + &tree, + &mut matcher, + target, + Some("target"), + &["target".to_string()], + "div", + &mut style, + &parent_props, + None, + ); + + assert_eq!(style.width, crate::Dimension::Px(37.0)); + assert!( + effective.is_none(), + "an inherited var() use and `--` inside a value must not allocate a new property map" + ); + } + #[test] fn layer_order_applies_to_custom_properties_and_pseudos() { let (style, props, before) = cascade_layer_target( @@ -4495,7 +5240,10 @@ mod tests { None, ); assert_eq!(props.get("--normal-size").map(String::as_str), Some("30px")); - assert_eq!(props.get("--critical-size").map(String::as_str), Some("11px")); + assert_eq!( + props.get("--critical-size").map(String::as_str), + Some("11px") + ); assert_eq!(style.width, crate::Dimension::Px(30.0)); assert_eq!(style.height, crate::Dimension::Px(11.0)); let before = before.expect("the layered ::before content should materialize"); @@ -4549,9 +5297,8 @@ mod tests { #[test] fn generated_content_resolves_host_attributes_and_resets() { - let tree = obscura_dom::parse_html( - r#""#, - ); + let tree = + obscura_dom::parse_html(r#""#); let cta = tree.query_selector("#cta").unwrap().unwrap(); assert_eq!( extract_content("content:attr(data-label)", &tree, cta), @@ -4619,7 +5366,10 @@ mod tests { let keyframes: HashMap<_, _> = extract_keyframes(css).into_iter().collect(); let dismiss = normalized_keyframe_offsets(&keyframes["dismiss"].stops); assert_eq!( - dismiss.iter().map(|(offset, _)| *offset).collect::>(), + dismiss + .iter() + .map(|(offset, _)| *offset) + .collect::>(), [0.0, 0.5, 1.0] ); assert!(dismiss[0].1.declarations.contains("opacity: 1")); @@ -5147,11 +5897,11 @@ mod tests { let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0)); let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0)); assert!(narrow - .iter() - .any(|(selector, declarations)| selector == ".card" + .iter() + .any(|(selector, declarations)| selector == ".card" && declarations.contains("width:100%"))); assert!(!wide - .iter() + .iter() .any(|(_, declarations)| declarations.contains("width:100%"))); } @@ -5168,7 +5918,10 @@ mod tests { "#; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( - css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, + css, + (1280.0, 720.0), + &mut conditions, + ContainerConditionId::NONE, ); assert_eq!(parsed.len(), 3); assert_eq!(parsed[1].container_condition_id, ContainerConditionId(1)); @@ -5262,8 +6015,7 @@ mod tests { let registration = parsed .iter() .find(|rule| { - rule.selector - == format!("{PROPERTY_REGISTRATION_SELECTOR_PREFIX}--cq-token") + rule.selector == format!("{PROPERTY_REGISTRATION_SELECTOR_PREFIX}--cq-token") }) .expect("@property initial value should be registered"); assert_eq!( @@ -5289,7 +6041,12 @@ mod tests { let element = node.as_element().unwrap(); let classes = node .get_attribute("class") - .map(|value| value.split_whitespace().map(str::to_string).collect::>()) + .map(|value| { + value + .split_whitespace() + .map(str::to_string) + .collect::>() + }) .unwrap_or_default(); let mut matcher = tree.matcher(); let mut style = LayoutStyle::default(); @@ -5376,12 +6133,8 @@ mod tests { let override_id = tree.get_element_by_id("override").unwrap(); let reset_id = tree.get_element_by_id("reset").unwrap(); let invalid_id = tree.get_element_by_id("invalid").unwrap(); - let (overridden, _) = apply_registered_property_test_style( - &sheet, - &tree, - override_id, - &HashMap::new(), - ); + let (overridden, _) = + apply_registered_property_test_style(&sheet, &tree, override_id, &HashMap::new()); assert_eq!(overridden.width, crate::Dimension::Percent(0.60)); assert_eq!(overridden.height, crate::Dimension::Percent(0.12)); let (reset, _) = @@ -5712,9 +6465,7 @@ mod tests { #[test] fn container_evaluator_honors_tailwind_threshold_and_cache() { - let tree = obscura_dom::parse_html( - r#"
"#, - ); + let tree = obscura_dom::parse_html(r#"
"#); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( @@ -5740,13 +6491,11 @@ mod tests { ); snapshot }; - let (below, _, _) = - evaluate_container_styles(&tree, &sheet, target, &snapshot(447.0)); + let (below, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(447.0)); assert_eq!(below.width, crate::Dimension::Px(1.0)); assert_eq!(below.height, crate::Dimension::Px(1.0)); - let (at, _, stats) = - evaluate_container_styles(&tree, &sheet, target, &snapshot(448.0)); + let (at, _, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot(448.0)); assert_eq!(at.width, crate::Dimension::Px(2.0)); assert_eq!(at.height, crate::Dimension::Px(2.0)); assert_eq!(stats.evaluations, 1); @@ -5755,9 +6504,7 @@ mod tests { #[test] fn container_evaluator_matches_selector_before_ancestor_lookup() { - let tree = obscura_dom::parse_html( - r#"
"#, - ); + let tree = obscura_dom::parse_html(r#"
"#); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( @@ -5777,8 +6524,7 @@ mod tests { container, container_box(crate::ContainerType::InlineSize, &[], 500.0, 16.0), ); - let (_, signature, stats) = - evaluate_container_styles(&tree, &sheet, target, &snapshot); + let (_, signature, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(stats.evaluations, 0); assert_eq!(stats.ancestor_steps, 0); assert!(signature.decisions.is_empty()); @@ -5807,24 +6553,13 @@ mod tests { }; snapshot.boxes.insert( outer, - container_box( - crate::ContainerType::InlineSize, - &["shell"], - 600.0, - 16.0, - ), + container_box(crate::ContainerType::InlineSize, &["shell"], 600.0, 16.0), ); snapshot.boxes.insert( inner, - container_box( - crate::ContainerType::InlineSize, - &["other"], - 300.0, - 16.0, - ), + container_box(crate::ContainerType::InlineSize, &["other"], 300.0, 16.0), ); - let (style, _, stats) = - evaluate_container_styles(&tree, &sheet, target, &snapshot); + let (style, _, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(11.0)); assert_eq!(style.height, crate::Dimension::Px(1.0)); assert!(stats.ancestor_steps >= 3); @@ -5832,9 +6567,7 @@ mod tests { #[test] fn container_query_em_uses_container_font_and_rem_uses_root_font() { - let tree = obscura_dom::parse_html( - r#"
"#, - ); + let tree = obscura_dom::parse_html(r#"
"#); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( @@ -5854,16 +6587,14 @@ mod tests { container, container_box(crate::ContainerType::InlineSize, &[], 400.0, 10.0), ); - let (style, _, _) = - evaluate_container_styles(&tree, &sheet, target, &snapshot); + let (style, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(3.0)); assert_eq!(style.height, crate::Dimension::Px(1.0)); } #[test] fn container_snapshot_compares_only_axes_exposed_by_container_type() { - let mut inline_a = - container_box(crate::ContainerType::InlineSize, &[], 400.0, 16.0); + let mut inline_a = container_box(crate::ContainerType::InlineSize, &[], 400.0, 16.0); let mut inline_b = inline_a.clone(); inline_a.content_height = 100.0; inline_b.content_height = 900.0; @@ -5903,12 +6634,7 @@ mod tests { }; snapshot.boxes.insert( outer, - container_box( - crate::ContainerType::InlineSize, - &["outer"], - 600.0, - 16.0, - ), + container_box(crate::ContainerType::InlineSize, &["outer"], 600.0, 16.0), ); snapshot.boxes.insert( inner, @@ -5921,19 +6647,15 @@ mod tests { ); snapshot }; - let (matching, _, _) = - evaluate_container_styles(&tree, &sheet, target, &snapshot(200.0)); + let (matching, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(200.0)); assert_eq!(matching.width, crate::Dimension::Px(9.0)); - let (failing, _, _) = - evaluate_container_styles(&tree, &sheet, target, &snapshot(199.0)); + let (failing, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(199.0)); assert_eq!(failing.width, crate::Dimension::Px(1.0)); } #[test] fn unknown_container_alternative_does_not_mask_true_alternative() { - let tree = obscura_dom::parse_html( - r#"
"#, - ); + let tree = obscura_dom::parse_html(r#"
"#); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( @@ -5954,15 +6676,13 @@ mod tests { container, container_box(crate::ContainerType::InlineSize, &[], 100.0, 16.0), ); - let (style, _, _) = - evaluate_container_styles(&tree, &sheet, target, &snapshot); + let (style, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(7.0)); } #[test] fn container_query_boolean_evaluation_uses_kleene_truth_tables() { - let container = - container_box(crate::ContainerType::InlineSize, &[], 200.0, 16.0); + let container = container_box(crate::ContainerType::InlineSize, &[], 200.0, 16.0); let min_width = |threshold| { ContainerQueryExpr::Feature(ContainerSizeFeature { axis: ContainerQueryAxis::Width, @@ -5972,10 +6692,7 @@ mod tests { }; assert_eq!( evaluate_container_query_expr( - &ContainerQueryExpr::Or(vec![ - min_width(100.0), - ContainerQueryExpr::Unknown, - ]), + &ContainerQueryExpr::Or(vec![min_width(100.0), ContainerQueryExpr::Unknown,]), &container, 16.0, ), @@ -5983,10 +6700,7 @@ mod tests { ); assert_eq!( evaluate_container_query_expr( - &ContainerQueryExpr::And(vec![ - min_width(300.0), - ContainerQueryExpr::Unknown, - ]), + &ContainerQueryExpr::And(vec![min_width(300.0), ContainerQueryExpr::Unknown,]), &container, 16.0, ), @@ -5994,10 +6708,7 @@ mod tests { ); assert_eq!( evaluate_container_query_expr( - &ContainerQueryExpr::Or(vec![ - min_width(300.0), - ContainerQueryExpr::Unknown, - ]), + &ContainerQueryExpr::Or(vec![min_width(300.0), ContainerQueryExpr::Unknown,]), &container, 16.0, ), @@ -6015,8 +6726,7 @@ mod tests { #[test] fn container_range_syntax_supports_strict_inclusive_and_chained_queries() { - let container = - container_box(crate::ContainerType::Size, &[], 200.0, 16.0); + let container = container_box(crate::ContainerType::Size, &[], 200.0, 16.0); for query in [ "(width)", "(width > 199px)", @@ -6026,8 +6736,7 @@ mod tests { "(height = 100px)", "(block-size >= 100px)", ] { - let expression = - parse_container_query_expr(query).expect("valid range query"); + let expression = parse_container_query_expr(query).expect("valid range query"); assert_eq!( evaluate_container_query_expr(&expression, &container, 16.0), ContainerQueryTruth::True, @@ -6040,8 +6749,7 @@ mod tests { "(200px < width < 300px)", "(height > 100px)", ] { - let expression = - parse_container_query_expr(query).expect("valid range query"); + let expression = parse_container_query_expr(query).expect("valid range query"); assert_eq!( evaluate_container_query_expr(&expression, &container, 16.0), ContainerQueryTruth::False, @@ -6063,9 +6771,7 @@ mod tests { #[test] fn block_axis_query_requires_size_container() { - let tree = obscura_dom::parse_html( - r#"
"#, - ); + let tree = obscura_dom::parse_html(r#"
"#); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( @@ -6081,10 +6787,9 @@ mod tests { root_font_size: 16.0, ..Default::default() }; - snapshot.boxes.insert( - container, - container_box(container_type, &[], 300.0, 16.0), - ); + snapshot + .boxes + .insert(container, container_box(container_type, &[], 300.0, 16.0)); snapshot }; let (inline_only, _, _) = evaluate_container_styles( @@ -6094,12 +6799,8 @@ mod tests { &snapshot(crate::ContainerType::InlineSize), ); assert_eq!(inline_only.width, crate::Dimension::Px(1.0)); - let (size, _, _) = evaluate_container_styles( - &tree, - &sheet, - target, - &snapshot(crate::ContainerType::Size), - ); + let (size, _, _) = + evaluate_container_styles(&tree, &sheet, target, &snapshot(crate::ContainerType::Size)); assert_eq!(size.width, crate::Dimension::Px(8.0)); } @@ -6109,7 +6810,10 @@ mod tests { @container (max-inline-size:50rem){.card{display:grid}}}"; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( - css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, + css, + (1280.0, 720.0), + &mut conditions, + ContainerConditionId::NONE, ); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].container_condition_id, ContainerConditionId(2)); @@ -6121,21 +6825,32 @@ mod tests { fn unresolved_container_rules_do_not_enter_cascade_or_pseudos() { let tree = obscura_dom::parse_html(r#"
"#); let target = tree.query_selector("#target").unwrap().unwrap(); - let sheet = Stylesheet::parse(&tree, &[r#" + let sheet = Stylesheet::parse( + &tree, + &[r#" #target{width:10px} @container (min-width:28rem){ #target{width:999px} #target::before{content:"inactive"} } #target{height:20px} - "#.to_string()]); + "# + .to_string()], + ); assert_eq!(sheet.rules.len(), 3, "conditional rule remains indexed"); assert_eq!(sheet.container_conditions.len(), 2); let mut matcher = tree.matcher(); let mut style = LayoutStyle::default(); sheet.apply( - &tree, &mut matcher, target, Some("target"), &[], "div", &mut style, - &HashMap::new(), None, + &tree, + &mut matcher, + target, + Some("target"), + &[], + "div", + &mut style, + &HashMap::new(), + None, ); assert_eq!(style.width, crate::Dimension::Px(10.0)); assert_eq!(style.height, crate::Dimension::Px(20.0)); @@ -6161,10 +6876,15 @@ mod tests { ); let mut conditions = condition_arena_root(); let rich = parse_stylesheet_for_viewport_preserving_containers( - css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, + css, + (1280.0, 720.0), + &mut conditions, + ContainerConditionId::NONE, ); assert_eq!(conditions.len(), 1); - assert!(rich.iter().all(|rule| rule.container_condition_id == ContainerConditionId::NONE)); + assert!(rich + .iter() + .all(|rule| rule.container_condition_id == ContainerConditionId::NONE)); } #[test] @@ -6203,8 +6923,12 @@ mod tests { #[test] fn supports_conditions_reject_invalid_boolean_grammar() { - assert!(supports_condition_applies("(display:grid) and (word-break:break-all)")); - assert!(supports_condition_applies("(display:grid) or (unknown:value)")); + assert!(supports_condition_applies( + "(display:grid) and (word-break:break-all)" + )); + assert!(supports_condition_applies( + "(display:grid) or (unknown:value)" + )); assert!(!supports_condition_applies( "(display:grid) and (word-break:break-all) or (display:flex)" )); @@ -6310,9 +7034,13 @@ mod tests { assert!(narrow.iter().any(|(selector, declarations)| { selector == "header .menu-toolkit" && declarations.contains("display: none") })); - assert!(!narrow.iter().any(|(_, declarations)| declarations.contains("display: flex"))); + assert!(!narrow + .iter() + .any(|(_, declarations)| declarations.contains("display: flex"))); let wide = parse_stylesheet_for_viewport(css, (1024.0, 768.0)); - assert!(wide.iter().any(|(_, declarations)| declarations.contains("display: flex"))); + assert!(wide + .iter() + .any(|(_, declarations)| declarations.contains("display: flex"))); } } diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 9a99ea2..c533234 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -7,11 +7,12 @@ //! correct. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use obscura_dom::tree::{DomTree, NodeId}; use taffy::prelude::*; -use crate::{Rect, to_taffy_style}; +use crate::{to_taffy_style, Rect}; /// Text width for layout. With the `paint` feature this is exact (real glyph /// metrics from the embedded font, shared with rasterization). Without it @@ -43,8 +44,11 @@ fn text_width( const AVG_CHAR_WIDTH_EM: f32 = 0.55; let chars = text.chars().filter(|c| !c.is_control()).count() as f32; let glyph_width = chars * size * AVG_CHAR_WIDTH_EM; - (if is_bold { glyph_width * 1.08 } else { glyph_width }) - + chars * letter_spacing + (if is_bold { + glyph_width * 1.08 + } else { + glyph_width + }) + chars * letter_spacing } #[derive(Default)] @@ -143,26 +147,67 @@ fn native_button_intrinsic_content( /// `None` on an axis means unbounded on that axis. This avoids representing /// `overflow-x:clip` with an artificial enormous Y rectangle, which can /// corrupt scrolling overflow and transformed clip intersections. -#[derive(Debug, Clone, Copy, Default, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RoundedOverflowClip { + pub rect: Rect, + pub radii: crate::ResolvedBorderRadii, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RoundedOverflowClipChain { + pub clip: RoundedOverflowClip, + pub parent: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq)] pub struct OverflowClip { x: Option<(f32, f32)>, y: Option<(f32, f32)>, + rounded: Option>, + rounded_offset: (f32, f32), } impl OverflowClip { - pub(crate) fn for_box( - rect: &Rect, - style: &crate::LayoutStyle, - tx: f32, - ty: f32, - ) -> Self { + pub(crate) fn for_box(rect: &Rect, style: &crate::LayoutStyle, tx: f32, ty: f32) -> Self { let left = rect.x + tx + style.border.left; let top = rect.y + ty + style.border.top; let right = (rect.x + tx + rect.width - style.border.right).max(left); let bottom = (rect.y + ty + rect.height - style.border.bottom).max(top); + let clips_x = style.clips_overflow_x(); + let clips_y = style.clips_overflow_y(); + let padding_rect = Rect { + x: left, + y: top, + width: (right - left).max(0.0), + height: (bottom - top).max(0.0), + }; + let radii = style + .border_model + .radii + .resolve(rect.width, rect.height) + .inset(crate::Sides { + top: style.border.top, + right: style.border.right, + bottom: style.border.bottom, + left: style.border.left, + }); + // A rounded corner constrains both axes. Keep the existing independent + // rectangular representation for one-axis overflow clips; applying a + // closed rounded path there would incorrectly bound the visible axis. + let rounded = (clips_x && clips_y && !radii.is_zero()).then(|| { + Arc::new(RoundedOverflowClipChain { + clip: RoundedOverflowClip { + rect: padding_rect, + radii, + }, + parent: None, + }) + }); Self { - x: style.clips_overflow_x().then_some((left, right)), - y: style.clips_overflow_y().then_some((top, bottom)), + x: clips_x.then_some((left, right)), + y: clips_y.then_some((top, bottom)), + rounded, + rounded_offset: (0.0, 0.0), } } @@ -175,21 +220,39 @@ impl OverflowClip { (Some(bounds), None) | (None, Some(bounds)) => Some(bounds), (None, None) => None, }; + let mut rounded = self.rounded; + let mut other_clips = Vec::new(); + let mut current = other.rounded.as_deref(); + while let Some(node) = current { + let mut clip = node.clip; + clip.rect.x += other.rounded_offset.0 - self.rounded_offset.0; + clip.rect.y += other.rounded_offset.1 - self.rounded_offset.1; + other_clips.push(clip); + current = node.parent.as_deref(); + } + for clip in other_clips.into_iter().rev() { + rounded = Some(Arc::new(RoundedOverflowClipChain { + clip, + parent: rounded, + })); + } Self { x: axis(self.x, other.x), y: axis(self.y, other.y), + rounded, + rounded_offset: self.rounded_offset, } } - pub(crate) fn intersect_rect(self, rect: &Rect) -> Option { + pub(crate) fn intersect_rect(&self, rect: &Rect) -> Option { let left = self.x.map_or(rect.x, |(start, _)| rect.x.max(start)); - let right = self - .x - .map_or(rect.x + rect.width, |(_, end)| (rect.x + rect.width).min(end)); + let right = self.x.map_or(rect.x + rect.width, |(_, end)| { + (rect.x + rect.width).min(end) + }); let top = self.y.map_or(rect.y, |(start, _)| rect.y.max(start)); - let bottom = self - .y - .map_or(rect.y + rect.height, |(_, end)| (rect.y + rect.height).min(end)); + let bottom = self.y.map_or(rect.y + rect.height, |(_, end)| { + (rect.y + rect.height).min(end) + }); (right > left && bottom > top).then_some(Rect { x: left, y: top, @@ -207,9 +270,19 @@ impl OverflowClip { *start += dy; *end += dy; } + self.rounded_offset.0 += dx; + self.rounded_offset.1 += dy; } - pub(crate) fn viewport_rect(self, viewport: (f32, f32)) -> Rect { + pub(crate) fn rounded_chain(&self) -> Option<&Arc> { + self.rounded.as_ref() + } + + pub(crate) fn rounded_offset(&self) -> (f32, f32) { + self.rounded_offset + } + + pub(crate) fn viewport_rect(&self, viewport: (f32, f32)) -> Rect { let (left, right) = self.x.unwrap_or((0.0, viewport.0)); let (top, bottom) = self.y.unwrap_or((0.0, viewport.1)); Rect { @@ -235,8 +308,7 @@ pub struct DomLayout { /// without an override: an entire subtree can share one cascade map while /// CSSOM still exposes every inherited `--token` through /// `getComputedStyle()`. - pub custom_properties: - HashMap>>, + pub custom_properties: HashMap>>, /// The per-axis clip inherited from ancestor non-visible overflow, keyed /// per node, in SCREEN space (the clip owner's box shifted by the owner's /// accumulated translate; see `resolve_clip_rects`). `None` means @@ -428,9 +500,7 @@ impl StickyLayout { let frame_offsets = self.frame_offsets(viewport, scroll); self.owners .iter() - .filter_map(|(&id, owner)| { - frame_offsets.get(owner).copied().map(|offset| (id, offset)) - }) + .filter_map(|(&id, owner)| frame_offsets.get(owner).copied().map(|offset| (id, offset))) .collect() } @@ -601,9 +671,8 @@ impl DomLayout { let style = laid.styles.get(&id); let rect = laid.rects.get(&id).copied(); let establishes = style.is_some_and(|style| { - style.overflow_scroll_container && !style.overflow_propagated_to_viewport - }) - && rect.is_some() + style.overflow_scroll_container && !style.overflow_propagated_to_viewport + }) && rect.is_some() && !affine_coordinate_space .get(id.index()) .copied() @@ -685,63 +754,60 @@ impl DomLayout { let visual_padding = visual(padding_rect); let mut local = visual_padding; for child in rendered_children(tree, id) { - let Some(child_overflow) = overflow_bounds - .get(child.index()) - .copied() - .flatten() + let Some(child_overflow) = overflow_bounds.get(child.index()).copied().flatten() else { continue; }; - let contribution = if let Some(style) = self.styles.get(&child).filter(|style| { - style.overflow_hidden && !style.overflow_propagated_to_viewport - }) { - let border_box = self.rects - .get(&child) - .copied() - .map(|rect| { - self.transforms - .get(&child) - .copied() - .map(|transform| transform.map_rect(rect)) - .unwrap_or(rect) - }) - .unwrap_or(child_overflow); - let x0 = if style.clips_overflow_x() { - border_box.x + let contribution = + if let Some(style) = self.styles.get(&child).filter(|style| { + style.overflow_hidden && !style.overflow_propagated_to_viewport + }) { + let border_box = self + .rects + .get(&child) + .copied() + .map(|rect| { + self.transforms + .get(&child) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect) + }) + .unwrap_or(child_overflow); + let x0 = if style.clips_overflow_x() { + border_box.x + } else { + child_overflow.x + }; + let x1 = if style.clips_overflow_x() { + border_box.x + border_box.width + } else { + child_overflow.x + child_overflow.width + }; + let y0 = if style.clips_overflow_y() { + border_box.y + } else { + child_overflow.y + }; + let y1 = if style.clips_overflow_y() { + border_box.y + border_box.height + } else { + child_overflow.y + child_overflow.height + }; + Rect { + x: x0, + y: y0, + width: (x1 - x0).max(0.0), + height: (y1 - y0).max(0.0), + } } else { - child_overflow.x + child_overflow }; - let x1 = if style.clips_overflow_x() { - border_box.x + border_box.width - } else { - child_overflow.x + child_overflow.width - }; - let y0 = if style.clips_overflow_y() { - border_box.y - } else { - child_overflow.y - }; - let y1 = if style.clips_overflow_y() { - border_box.y + border_box.height - } else { - child_overflow.y + child_overflow.height - }; - Rect { - x: x0, - y: y0, - width: (x1 - x0).max(0.0), - height: (y1 - y0).max(0.0), - } - } else { - child_overflow - }; local = local.union(&contribution); } let mut content = ( - (local.x + local.width - visual_padding.x) - .max(visual_padding.width), - (local.y + local.height - visual_padding.y) - .max(visual_padding.height), + (local.x + local.width - visual_padding.x).max(visual_padding.width), + (local.y + local.height - visual_padding.y).max(visual_padding.height), ); // Scroll containers include trailing end padding in their // scrolling area. Visible/clip boxes expose descendant overflow @@ -755,11 +821,13 @@ impl DomLayout { } } if let Some(slot) = node_content_size.get_mut(id.index()) { - *slot = Some(if style.is_some_and(|style| style.ignores_used_box_sizes()) { - (0.0, 0.0) - } else { - content - }); + *slot = Some( + if style.is_some_and(|style| style.ignores_used_box_sizes()) { + (0.0, 0.0) + } else { + content + }, + ); } if let Some(slot) = overflow_bounds.get_mut(id.index()) { *slot = Some(local); @@ -843,7 +911,7 @@ impl DomLayout { let parent = tree.get_node(id).and_then(|node| node.parent); let has_nested_scroll_container = parent.is_some_and(|parent| { inside_nested_scroller - .get(&parent) + .get(&parent) .copied() .unwrap_or(false) || self.styles.get(&parent).is_some_and(|style| { @@ -856,13 +924,13 @@ impl DomLayout { .and_then(|parent| nearest_sticky.get(&parent).copied()) .flatten(); let inherited_clip = parent.and_then(|parent| { - let parent_style = self.styles.get(&parent); - if parent_style.is_some_and(|style| style.overflow_hidden) { - nearest_sticky.get(&parent).copied().flatten() - } else { - inherited_clip_sticky.get(&parent).copied().flatten() - } - }); + let parent_style = self.styles.get(&parent); + if parent_style.is_some_and(|style| style.overflow_hidden) { + nearest_sticky.get(&parent).copied().flatten() + } else { + inherited_clip_sticky.get(&parent).copied().flatten() + } + }); if let Some(owner) = inherited_clip { layout.clip_owners.insert(id, owner); } @@ -912,8 +980,11 @@ impl DomLayout { if candidate_style.display != crate::Display::Inline && !candidate_style.display_contents { - let (ctx, cty) = - self.translates.get(&candidate).copied().unwrap_or((0.0, 0.0)); + let (ctx, cty) = self + .translates + .get(&candidate) + .copied() + .unwrap_or((0.0, 0.0)); containing = Some(Rect { x: candidate_rect.x + ctx @@ -994,7 +1065,7 @@ fn accumulate_scrolling_overflow( .unwrap_or(*rect) }); if let Some(overflow) = translated { - let visible = if let Some(clip) = inherited_clip { + let visible = if let Some(ref clip) = inherited_clip { clip.intersect_rect(&overflow) } else { Some(overflow) @@ -1024,15 +1095,21 @@ fn accumulate_scrolling_overflow( }; for child in tree.children(id) { accumulate_scrolling_overflow( - tree, child, child_clip, fixed, rects, styles, translates, transforms, right, bottom, + tree, + child, + child_clip.clone(), + fixed, + rects, + styles, + translates, + transforms, + right, + bottom, ); } } -fn is_viewport_overflow_source( - id: NodeId, - styles: &HashMap, -) -> bool { +fn is_viewport_overflow_source(id: NodeId, styles: &HashMap) -> bool { styles .get(&id) .is_some_and(|style| style.overflow_propagated_to_viewport) @@ -1050,9 +1127,7 @@ fn mark_viewport_overflow_source( if !root_is_html { return; } - let root_owns_overflow = styles - .get(&root) - .is_some_and(|style| style.overflow_hidden); + let root_owns_overflow = styles.get(&root).is_some_and(|style| style.overflow_hidden); if let Some(style) = styles.get_mut(&root) { style.overflow_propagated_to_viewport = true; } @@ -1304,12 +1379,12 @@ fn sync_positioned_pseudo_percentage_padding( style.before_pseudo.as_deref(), style.after_pseudo.as_deref(), ] - .into_iter() - .flatten() - .any(|pseudo| { - pseudo.position == Some(taffy::Position::Absolute) - && pseudo.padding_percent.iter().any(Option::is_some) - }) + .into_iter() + .flatten() + .any(|pseudo| { + pseudo.position == Some(taffy::Position::Absolute) + && pseudo.padding_percent.iter().any(Option::is_some) + }) }); if !has_positioned_percentage_padding { return; @@ -1332,9 +1407,10 @@ fn sync_positioned_pseudo_percentage_padding( } for (host, style) in styles { - let Some(rect) = rects.get(host) else { continue }; - let containing_block_width = - (rect.width - style.border.left - style.border.right).max(0.0); + let Some(rect) = rects.get(host) else { + continue; + }; + let containing_block_width = (rect.width - style.border.left - style.border.right).max(0.0); if let Some(pseudo) = style.before_pseudo.as_deref_mut() { sync(pseudo, containing_block_width); } @@ -1421,7 +1497,7 @@ fn resolve_clip_rects( root_font_size: f32, viewport: (f32, f32), ) { - clip_rects.insert(id, inherited); + clip_rects.insert(id, inherited.clone()); // This node's own translate joins the accumulation for its box and its // whole subtree (percentages resolve against its own border box). let (own_tx, own_ty) = styles.get(&id).map_or((0.0, 0.0), |style| { @@ -1461,7 +1537,7 @@ fn resolve_clip_rects( resolve_clip_rects( tree, cid, - next, + next.clone(), tx, ty, rects, @@ -1647,8 +1723,12 @@ fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate: } } if style.aspect_ratio.is_none() { - let aw = node.get_attribute("width").and_then(|w| w.parse::().ok()); - let ah = node.get_attribute("height").and_then(|h| h.parse::().ok()); + let aw = node + .get_attribute("width") + .and_then(|w| w.parse::().ok()); + let ah = node + .get_attribute("height") + .and_then(|h| h.parse::().ok()); if let (Some(w), Some(h)) = (aw, ah) { if w > 0.0 && h > 0.0 { style.aspect_ratio = Some(w / h); @@ -1694,7 +1774,9 @@ fn apply_picture_source_hints( viewport: (f32, f32), style: &mut crate::LayoutStyle, ) { - let Some(img) = tree.get_node(img_id) else { return }; + let Some(img) = tree.get_node(img_id) else { + return; + }; if img .as_element() .map_or(true, |element| element.local.as_ref() != "img") @@ -1703,10 +1785,10 @@ fn apply_picture_source_hints( } let Some(parent_id) = img.parent else { return }; let is_picture = tree.get_node(parent_id).is_some_and(|parent| { - parent - .as_element() - .is_some_and(|element| element.local.as_ref() == "picture") - }); + parent + .as_element() + .is_some_and(|element| element.local.as_ref() == "picture") + }); if !is_picture { return; } @@ -1716,7 +1798,9 @@ fn apply_picture_source_hints( if child_id == img_id { break; } - let Some(source) = tree.get_node(child_id) else { continue }; + let Some(source) = tree.get_node(child_id) else { + continue; + }; if source .as_element() .map_or(true, |element| element.local.as_ref() != "source") @@ -1789,7 +1873,9 @@ fn cascade_walk( inherited_cell_padding: Option, inherited_color_scheme_dark: bool, ) { - let Some(node) = tree.get_node(id) else { return }; + let Some(node) = tree.get_node(id) else { + return; + }; let is_element = node.is_element(); // The custom-property map in force for this node's subtree: the parent's, // unless this element declares its own `--x` (then a richer map). @@ -2211,6 +2297,7 @@ const CONTAINER_LAYOUT_SAFETY_LIMIT: usize = 512; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ContainerLayoutTermination { NoQueries, + NoContainers, GeometryStable, SignatureStable, OscillationFallback, @@ -2247,13 +2334,31 @@ pub(crate) fn layout_dom_with_web_fonts( layout_dom_with_web_fonts_measured(tree, viewport, intrinsic, fonts).0 } +pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, +) -> DomLayout { + layout_dom_with_web_fonts_pass_limit( + tree, + viewport, + intrinsic, + fonts, + None, + Some(stylesheet_cache), + ) + .0 +} + fn layout_dom_with_web_fonts_measured( tree: &DomTree, viewport: (f32, f32), intrinsic: &HashMap, fonts: &[crate::inline::WebFont], ) -> (DomLayout, ContainerLayoutTelemetry) { - layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None) + layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None, None) } fn layout_dom_with_web_fonts_pass_limit( @@ -2262,6 +2367,7 @@ fn layout_dom_with_web_fonts_pass_limit( intrinsic: &HashMap, fonts: &[crate::inline::WebFont], pass_limit: Option, + stylesheet_cache: Option<&mut crate::css::StylesheetCache>, ) -> (DomLayout, ContainerLayoutTelemetry) { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); @@ -2278,21 +2384,25 @@ fn layout_dom_with_web_fonts_pass_limit( } let t0 = std::time::Instant::now(); - let sheet = crate::css::Stylesheet::parse_for_viewport(tree, &css_sources, viewport); + let (sheet, stylesheet_cache_hit) = match stylesheet_cache { + Some(cache) => cache.get_or_parse(tree, &css_sources, viewport), + None => ( + std::sync::Arc::new(crate::css::Stylesheet::parse_for_viewport( + tree, + &css_sources, + viewport, + )), + false, + ), + }; let t_parse = t0.elapsed(); - let (mut laid, _, mut query, mut cascade_time) = layout_dom_once( - tree, - viewport, - intrinsic, - fonts, - &sheet, - None, - ); + let (mut laid, _, mut query, mut cascade_time) = + layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None); if !sheet.has_container_queries() { if timing { - let (r, i, c, l, u) = sheet.debug_stats(); - eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-queries", t_parse, cascade_time, r, i, c, l, u); + let (r, i, c, a, l, u) = sheet.debug_stats(); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-queries", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u); } return ( laid, @@ -2305,10 +2415,27 @@ fn layout_dom_with_web_fonts_pass_limit( } let mut snapshot = container_snapshot(tree, &laid); - let mut previous_candidate: Option<( - DomLayout, - crate::css::ContainerDecisionSignature, - )> = None; + // A container condition has no matching query container when the initial + // cascade produced neither container-type nor container-name. Re-running + // the entire cascade cannot create the first container because conditional + // rules are inactive until a container already exists. Large framework + // stylesheets commonly ship dormant @container blocks; keeping them on the + // one-pass path avoids a redundant whole-document layout. + if snapshot.boxes.is_empty() { + if timing { + let (r, i, c, a, l, u) = sheet.debug_stats(); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-containers", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u); + } + return ( + laid, + ContainerLayoutTelemetry { + passes: 1, + termination: ContainerLayoutTermination::NoContainers, + query, + }, + ); + } + let mut previous_candidate: Option<(DomLayout, crate::css::ContainerDecisionSignature)> = None; let mut seen_signatures = Vec::new(); let mut passes = 1; let mut termination = ContainerLayoutTermination::PassCapFallback; @@ -2343,14 +2470,8 @@ fn layout_dom_with_web_fonts_pass_limit( }); let mut needs_fallback = false; for pass in 2..=max_passes { - let (next, signature, pass_query, pass_cascade) = layout_dom_once( - tree, - viewport, - intrinsic, - fonts, - &sheet, - Some(&snapshot), - ); + let (next, signature, pass_query, pass_cascade) = + layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, Some(&snapshot)); passes = pass; query.evaluations += pass_query.evaluations; query.cache_hits += pass_query.cache_hits; @@ -2361,9 +2482,7 @@ fn layout_dom_with_web_fonts_pass_limit( if let Some(reason) = container_iteration_termination( next_snapshot == snapshot, &signature, - previous_candidate - .as_ref() - .map(|(_, signature)| signature), + previous_candidate.as_ref().map(|(_, signature)| signature), ) { termination = reason; // Equal adjacent signatures prove that the *previous* candidate's @@ -2398,14 +2517,8 @@ fn layout_dom_with_web_fonts_pass_limit( // we return a layout whose conditional declarations contradict the // geometry used to choose them. Disable the unstable conditional // rules for this render and expose the downgrade in telemetry. - let (fallback, _, fallback_query, fallback_cascade) = layout_dom_once( - tree, - viewport, - intrinsic, - fonts, - &sheet, - None, - ); + let (fallback, _, fallback_query, fallback_cascade) = + layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None); laid = fallback; passes += 1; query.evaluations += fallback_query.evaluations; @@ -2415,8 +2528,8 @@ fn layout_dom_with_web_fonts_pass_limit( } if timing { - let (r, i, c, l, u) = sheet.debug_stats(); - eprintln!("[timing] parse+index={:?} cascade_total={:?} rules={} id_keys={} class_keys={} local_keys={} universal={} cq_passes={} cq_termination={:?} cq_evaluations={} cq_cache_hits={} cq_ancestor_steps={}", t_parse, cascade_time, r, i, c, l, u, passes, termination, query.evaluations, query.cache_hits, query.ancestor_steps); + let (r, i, c, a, l, u) = sheet.debug_stats(); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade_total={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes={} cq_termination={:?} cq_evaluations={} cq_cache_hits={} cq_ancestor_steps={}", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u, passes, termination, query.evaluations, query.cache_hits, query.ancestor_steps); } ( laid, @@ -2455,10 +2568,10 @@ fn layout_dom_once( // combinators (".mw-body .firstHeading") fast-reject via the filter // instead of falling back to the always-true "can't reject" case. let quirks_mode = !tree.descendants(tree.document()).into_iter().any(|id| { - tree.get_node(id).map_or(false, |node| { - matches!(node.data, obscura_dom::tree::NodeData::Doctype { .. }) - }) - }); + tree.get_node(id).map_or(false, |node| { + matches!(node.data, obscura_dom::tree::NodeData::Doctype { .. }) + }) + }); cascade_walk( tree, tree.document(), @@ -2631,7 +2744,7 @@ fn layout_dom_once( (Some(px), _, _) => px, (None, _, Some(expression)) => { crate::style::resolve_contextual_length(expression, 16.0, 16.0, vw, vh, 16.0) - .unwrap_or(16.0) + .unwrap_or(16.0) } (None, Some(d), _) => match d.resolve(16.0, 16.0, vw, vh) { crate::Dimension::Px(p) => p, @@ -2659,19 +2772,18 @@ fn layout_dom_once( let mut child_cb_width = inh.cb_width; let mut child_cb_height_definite = false; let inherited_grid_auto_tracks = styles.get(&id).and_then(|style| { - (style.grid_auto_columns_inherit || style.grid_auto_rows_inherit) - .then(|| { - tree.get_node(id) - .and_then(|node| node.parent) - .and_then(|parent| styles.get(&parent)) - .map(|parent| { - ( - parent.grid_auto_columns.clone(), - parent.grid_auto_rows.clone(), - ) - }) - .unwrap_or_default() - }) + (style.grid_auto_columns_inherit || style.grid_auto_rows_inherit).then(|| { + tree.get_node(id) + .and_then(|node| node.parent) + .and_then(|parent| styles.get(&parent)) + .map(|parent| { + ( + parent.grid_auto_columns.clone(), + parent.grid_auto_rows.clone(), + ) + }) + .unwrap_or_default() + }) }); if let Some(style) = styles.get_mut(&id) { if style.grid_auto_columns_inherit { @@ -2704,18 +2816,16 @@ fn layout_dom_once( inh.is_inline_block = style.is_inline_block; inh.flow_root = style.flow_root; inh.is_table_box = style.is_table_box; - match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color } + match style.color { + Some(c) => inh.color = Some(c), + None => style.color = inh.color, + } // Resolve a relative font-size against the PARENT (em/%) or // ROOT (rem) font-size before inheriting it downward. let parent_fs = inh.font_size.unwrap_or(16.0); if let Some(expression) = style.font_size_expression.as_deref() { style.font_size = crate::style::resolve_contextual_length( - expression, - parent_fs, - root_fs, - vw, - vh, - parent_fs, + expression, parent_fs, root_fs, vw, vh, parent_fs, ); } else if let Some(raw) = style.font_size_raw { let resolved = match raw { @@ -2728,18 +2838,16 @@ fn layout_dom_once( }; style.font_size = Some(resolved); } - match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size } + match style.font_size { + Some(s) => inh.font_size = Some(s), + None => style.font_size = inh.font_size, + } // em in non-font-size properties is relative to this element's // OWN computed font-size; resolve every relative length now. let em_px = style.font_size.unwrap_or(parent_fs); if let Some(expression) = style.letter_spacing_expression.as_deref() { style.letter_spacing = crate::style::resolve_contextual_length( - expression, - em_px, - root_fs, - vw, - vh, - em_px, + expression, em_px, root_fs, vw, vh, em_px, ); } else if let Some(raw) = style.letter_spacing_raw { style.letter_spacing = match raw.resolve(em_px, root_fs, vw, vh) { @@ -2753,10 +2861,7 @@ fn layout_dom_once( } match style.letter_spacing_non_normal { Some(non_normal) => inh.letter_spacing_non_normal = non_normal, - None => { - style.letter_spacing_non_normal = - Some(inh.letter_spacing_non_normal) - } + None => style.letter_spacing_non_normal = Some(inh.letter_spacing_non_normal), } if style.container_type_inherit { style.container_type = inh.container_type; @@ -2811,34 +2916,20 @@ fn layout_dom_once( } if let Some(expression) = style.line_height_expression.as_deref() { if let Some(resolved) = crate::style::resolve_contextual_length( - expression, - em_px, - root_fs, - vw, - vh, - em_px, + expression, em_px, root_fs, vw, vh, em_px, ) { style.line_height = Some( - if crate::style::line_height_expression_is_length( - expression, - ) { + if crate::style::line_height_expression_is_length(expression) { crate::LineHeight::Px(resolved) } else { crate::LineHeight::Ratio(resolved) }, ); } - } else if let Some(crate::LineHeight::Relative(relative)) = - style.line_height - { + } else if let Some(crate::LineHeight::Relative(relative)) = style.line_height { let pixels = match relative { crate::Dimension::Percent(percent) => em_px * percent, - dimension => match dimension.resolve( - em_px, - root_fs, - vw, - vh, - ) { + dimension => match dimension.resolve(em_px, root_fs, vw, vh) { crate::Dimension::Px(pixels) => pixels, _ => em_px, }, @@ -2896,9 +2987,7 @@ fn layout_dom_once( definite_height_nodes.insert(id); } for index in 0..4 { - let Some(expression) = - style.inset_expressions[index].as_deref() - else { + let Some(expression) = style.inset_expressions[index].as_deref() else { continue; }; let percent_base = if matches!(index, 1 | 3) { @@ -2907,14 +2996,14 @@ fn layout_dom_once( viewport.1 }; style.inset[index] = crate::style::resolve_contextual_length( - expression, - em_px, - root_fs, - vw, - vh, - percent_base, - ) - .map(crate::Dimension::Px); + expression, + em_px, + root_fs, + vw, + vh, + percent_base, + ) + .map(crate::Dimension::Px); } for i in style.inset.iter_mut() { if let Some(d) = i { @@ -2945,9 +3034,8 @@ fn layout_dom_once( Some(crate::Dimension::Px(bottom)), ) = (style.inset[0], style.inset[2]) { - style.height = crate::Dimension::Px( - (viewport.1 - top - bottom).max(0.0), - ); + style.height = + crate::Dimension::Px((viewport.1 - top - bottom).max(0.0)); } } child_cb_height_definite = matches!( @@ -2961,7 +3049,10 @@ fn layout_dom_once( ); style.font_weight = Some(computed_weight.to_string()); inh.font_weight = computed_weight; - match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() } + match &style.font_family { + Some(f) => inh.font_family = Some(f.clone()), + None => style.font_family = inh.font_family.clone(), + } match style.font_optical_sizing { Some(value) => inh.font_optical_sizing = value, None => style.font_optical_sizing = Some(inh.font_optical_sizing), @@ -2969,14 +3060,13 @@ fn layout_dom_once( match &style.font_variation_settings { Some(settings) => inh.font_variation_settings.clone_from(settings), None => { - style.font_variation_settings = - Some(inh.font_variation_settings.clone()) + style.font_variation_settings = Some(inh.font_variation_settings.clone()) } } let is_table = tree.get_node(id).map_or(false, |node| { - node.as_element() - .map_or(false, |name| name.local.as_ref() == "table") - }); + node.as_element() + .map_or(false, |name| name.local.as_ref() == "table") + }); if is_table && inh.legacy_center && style.text_align.is_none() { // The vendor alignment used by
centers the table // outer box but does not leak into its internal formatting @@ -3007,16 +3097,39 @@ fn layout_dom_once( } inh.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden); inh.opacity_product *= style.opacity.unwrap_or(1.0); - style.effectively_invisible = - inh.visibility_hidden || inh.opacity_product <= 0.0; - match style.list_style { Some(v) => inh.list_style = v, None => style.list_style = Some(inh.list_style) } - match style.line_height { Some(v) => inh.line_height = v, None => style.line_height = Some(inh.line_height) } - match style.white_space { Some(v) => inh.white_space = v, None => style.white_space = Some(inh.white_space) } - match style.overflow_wrap { Some(v) => inh.overflow_wrap = v, None => style.overflow_wrap = Some(inh.overflow_wrap) } - match style.word_break { Some(v) => inh.word_break = v, None => style.word_break = Some(inh.word_break) } - match style.text_wrap_style { Some(v) => inh.text_wrap_style = v, None => style.text_wrap_style = Some(inh.text_wrap_style) } - match style.text_transform { Some(v) => inh.text_transform = v, None => style.text_transform = Some(inh.text_transform) } - match style.font_style_italic { Some(v) => inh.italic = v, None => style.font_style_italic = Some(inh.italic) } + style.effectively_invisible = inh.visibility_hidden || inh.opacity_product <= 0.0; + match style.list_style { + Some(v) => inh.list_style = v, + None => style.list_style = Some(inh.list_style), + } + match style.line_height { + Some(v) => inh.line_height = v, + None => style.line_height = Some(inh.line_height), + } + match style.white_space { + Some(v) => inh.white_space = v, + None => style.white_space = Some(inh.white_space), + } + match style.overflow_wrap { + Some(v) => inh.overflow_wrap = v, + None => style.overflow_wrap = Some(inh.overflow_wrap), + } + match style.word_break { + Some(v) => inh.word_break = v, + None => style.word_break = Some(inh.word_break), + } + match style.text_wrap_style { + Some(v) => inh.text_wrap_style = v, + None => style.text_wrap_style = Some(inh.text_wrap_style), + } + match style.text_transform { + Some(v) => inh.text_transform = v, + None => style.text_transform = Some(inh.text_transform), + } + match style.font_style_italic { + Some(v) => inh.italic = v, + None => style.font_style_italic = Some(inh.italic), + } if style.box_sizing == crate::BoxSizing::Inherit { style.box_sizing = inh.box_sizing; } @@ -3049,12 +3162,7 @@ fn layout_dom_once( for i in 0..4 { if let Some(expression) = style.padding_expressions[i].as_deref() { if let Some(px) = crate::style::resolve_contextual_length( - expression, - em_px, - root_fs, - vw, - vh, - cb_w, + expression, em_px, root_fs, vw, vh, cb_w, ) { match i { 0 => style.padding.top = px.max(0.0), @@ -3066,12 +3174,7 @@ fn layout_dom_once( } if let Some(expression) = style.margin_expressions[i].as_deref() { if let Some(px) = crate::style::resolve_contextual_length( - expression, - em_px, - root_fs, - vw, - vh, - cb_w, + expression, em_px, root_fs, vw, vh, cb_w, ) { match i { 0 => style.margin.top = px, @@ -3082,9 +3185,7 @@ fn layout_dom_once( } } if let Some(relative) = style.padding_relative[i] { - if let crate::Dimension::Px(px) = - relative.resolve(em_px, root_fs, vw, vh) - { + if let crate::Dimension::Px(px) = relative.resolve(em_px, root_fs, vw, vh) { match i { 0 => style.padding.top = px.max(0.0), 1 => style.padding.right = px.max(0.0), @@ -3094,9 +3195,7 @@ fn layout_dom_once( } } if let Some(relative) = style.margin_relative[i] { - if let crate::Dimension::Px(px) = - relative.resolve(em_px, root_fs, vw, vh) - { + if let crate::Dimension::Px(px) = relative.resolve(em_px, root_fs, vw, vh) { match i { 0 => style.margin.top = px, 1 => style.margin.right = px, @@ -3204,12 +3303,7 @@ fn layout_dom_once( pseudo.font_size = Some(match raw { crate::Dimension::Percent(percent) => host_font_size * percent, crate::Dimension::Em(value) => host_font_size * value, - dimension => match dimension.resolve( - host_font_size, - root_fs, - vw, - vh, - ) { + dimension => match dimension.resolve(host_font_size, root_fs, vw, vh) { crate::Dimension::Px(pixels) => pixels, _ => host_font_size, }, @@ -3220,24 +3314,18 @@ fn layout_dom_once( let pseudo_em = pseudo.font_size.unwrap_or(host_font_size); if let Some(expression) = pseudo.letter_spacing_expression.as_deref() { pseudo.letter_spacing = crate::style::resolve_contextual_length( - expression, - pseudo_em, - root_fs, - vw, - vh, - pseudo_em, + expression, pseudo_em, root_fs, vw, vh, pseudo_em, ); } else if let Some(raw) = pseudo.letter_spacing_raw { pseudo.letter_spacing = match raw.resolve(pseudo_em, root_fs, vw, vh) { crate::Dimension::Px(pixels) if pixels.is_finite() => Some(pixels), - _ => None, - }; + _ => None, + }; } else if pseudo.letter_spacing.is_none() { pseudo.letter_spacing = Some(host_letter_spacing); } if pseudo.letter_spacing_non_normal.is_none() { - pseudo.letter_spacing_non_normal = - Some(host_letter_spacing_non_normal); + pseudo.letter_spacing_non_normal = Some(host_letter_spacing_non_normal); } for index in 0..6 { if let Some(expression) = pseudo.size_expressions[index].as_deref() { @@ -3322,17 +3410,11 @@ fn layout_dom_once( pseudo.font_optical_sizing = host_optical_sizing; } if pseudo.font_variation_settings.is_none() { - pseudo.font_variation_settings = - host_variation_settings.clone(); + pseudo.font_variation_settings = host_variation_settings.clone(); } if let Some(expression) = pseudo.line_height_expression.as_deref() { if let Some(resolved) = crate::style::resolve_contextual_length( - expression, - pseudo_em, - root_fs, - vw, - vh, - pseudo_em, + expression, pseudo_em, root_fs, vw, vh, pseudo_em, ) { pseudo.line_height = Some( if crate::style::line_height_expression_is_length(expression) { @@ -3342,17 +3424,10 @@ fn layout_dom_once( }, ); } - } else if let Some(crate::LineHeight::Relative(relative)) = - pseudo.line_height - { + } else if let Some(crate::LineHeight::Relative(relative)) = pseudo.line_height { let pixels = match relative { crate::Dimension::Percent(percent) => pseudo_em * percent, - dimension => match dimension.resolve( - pseudo_em, - root_fs, - vw, - vh, - ) { + dimension => match dimension.resolve(pseudo_em, root_fs, vw, vh) { crate::Dimension::Px(pixels) => pixels, _ => pseudo_em, }, @@ -3388,8 +3463,7 @@ fn layout_dom_once( if pseudo.text_indent.is_none() { pseudo.text_indent = host_text_indent; } else if let Some(indent) = pseudo.text_indent { - pseudo.text_indent = - Some(indent.resolve(pseudo_em, root_fs, vw, vh)); + pseudo.text_indent = Some(indent.resolve(pseudo_em, root_fs, vw, vh)); } pseudo.effectively_invisible = host_invisible; }; @@ -3455,15 +3529,13 @@ fn layout_dom_once( && style.width == crate::Dimension::Auto && container_auto_inline_size(tree, id, style, &styles) != ContainerAutoInlineSize::StretchedGridItem) - .then( - || { + .then(|| { let font_size = style.font_size.unwrap_or(13.333_333).max(1.0); ( id, native_button_intrinsic_content(tree, id, &styles, font_size), ) - }, - ) + }) }) .collect(); let native_control_grid_stretch: HashMap = styles @@ -3511,12 +3583,12 @@ fn layout_dom_once( .collect::>() .join(" "); let mut content_width = text_width( - &label, - font_size, - bold, - style.font_family.as_deref(), - style.letter_spacing.unwrap_or(0.0), - ); + &label, + font_size, + bold, + style.font_family.as_deref(), + style.letter_spacing.unwrap_or(0.0), + ); content_width += intrinsic_content .map(|content| content.atomic_width) .unwrap_or(0.0); @@ -3568,10 +3640,10 @@ fn layout_dom_once( .into_iter() .filter(|option_id| { tree.get_node(*option_id).map_or(false, |option| { - option - .as_element() - .map_or(false, |name| name.local.as_ref() == "option") - }) + option + .as_element() + .map_or(false, |name| name.local.as_ref() == "option") + }) }) .map(|option_id| tree.text_content(option_id).trim().to_string()) .collect(); @@ -3604,27 +3676,26 @@ fn layout_dom_once( .unwrap_or(1) as f32; let intrinsic_width = label_width + horizontal_edges; let intrinsic_height = - crate::inline::used_line_height(style).max(1.0) * rows - + vertical_edges; - let (stretch_inline, stretch_block) = - native_control_grid_stretch.get(&id).copied().unwrap_or_default(); + crate::inline::used_line_height(style).max(1.0) * rows + vertical_edges; + let (stretch_inline, stretch_block) = native_control_grid_stretch + .get(&id) + .copied() + .unwrap_or_default(); if style.width == crate::Dimension::Auto && !stretch_inline { - style.width = crate::Dimension::Px( - if style.box_sizing == crate::BoxSizing::ContentBox { + style.width = + crate::Dimension::Px(if style.box_sizing == crate::BoxSizing::ContentBox { label_width } else { intrinsic_width - }, - ); + }); } if style.height == crate::Dimension::Auto && !stretch_block { - style.height = crate::Dimension::Px( - if style.box_sizing == crate::BoxSizing::ContentBox { + style.height = + crate::Dimension::Px(if style.box_sizing == crate::BoxSizing::ContentBox { (intrinsic_height - vertical_edges).max(0.0) } else { intrinsic_height - }, - ); + }); } continue; } @@ -3642,18 +3713,15 @@ fn layout_dom_once( } let font_size = style.font_size.unwrap_or(13.333_333).max(1.0); - let (stretch_inline, stretch_block) = - native_control_grid_stretch.get(&id).copied().unwrap_or_default(); - let horizontal_edges = style.padding.left - + style.padding.right - + style.border.left - + style.border.right; - let vertical_edges = style.padding.top - + style.padding.bottom - + style.border.top - + style.border.bottom; - let default_height = - crate::inline::used_line_height(style).max(1.0) + vertical_edges; + let (stretch_inline, stretch_block) = native_control_grid_stretch + .get(&id) + .copied() + .unwrap_or_default(); + let horizontal_edges = + style.padding.left + style.padding.right + style.border.left + style.border.right; + let vertical_edges = + style.padding.top + style.padding.bottom + style.border.top + style.border.bottom; + let default_height = crate::inline::used_line_height(style).max(1.0) + vertical_edges; let (intrinsic_width, intrinsic_height) = match input_type.as_str() { "checkbox" | "radio" => (13.0, 13.0), @@ -3683,9 +3751,7 @@ fn layout_dom_once( .filter(|&value| value > 0) .unwrap_or(20) as f32; ( - size * font_size * 0.6 - + font_size * 0.675 - + horizontal_edges, + size * font_size * 0.6 + font_size * 0.675 + horizontal_edges, default_height, ) } @@ -3723,8 +3789,7 @@ fn layout_dom_once( .iter() .filter(|(_, s)| { s.display == crate::Display::Grid - || (s.display == crate::Display::Flex - && !s.internal_flex_container) + || (s.display == crate::Display::Flex && !s.internal_flex_container) }) .map(|(&id, _)| id) .collect(); @@ -3808,20 +3873,32 @@ fn layout_dom_once( let deferred_cyclic_inline_sizes = defer_cyclic_flex_inline_sizes(tree, &mut styles, root_fs, vw, vh); - if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &mut words, &mut engine, &mut ifc_items, &styles) { + if let Some(taffy_root) = build( + tree, + root_id, + &mut taffy_tree, + &mut id_map, + &mut words, + &mut engine, + &mut ifc_items, + &styles, + ) { // Taffy has no outer display type and only gives an auto-width // Block root the initial-containing-block width. CSS blockifies // Flex/Grid roots too, so supply the equivalent used width while // leaving an authored root width untouched. if let Some(root_style) = styles.get(&root_id) { if root_style.width == crate::Dimension::Auto - && matches!(root_style.display, crate::Display::Flex | crate::Display::Grid) + && matches!( + root_style.display, + crate::Display::Flex | crate::Display::Grid + ) { if let Ok(current) = taffy_tree.style(taffy_root) { let mut adjusted = current.clone(); let outer = (initial_cb_width - root_style.margin.left - root_style.margin.right) - .max(0.0); + .max(0.0); let declared = if root_style.box_sizing == crate::BoxSizing::ContentBox { (outer - root_style.padding.left @@ -3837,8 +3914,13 @@ fn layout_dom_once( } } } - let static_position_candidates = - reparent_inset_positioned_nodes(tree, &mut taffy_tree, taffy_root, &id_map, &styles); + let static_position_candidates = reparent_inset_positioned_nodes( + tree, + &mut taffy_tree, + taffy_root, + &id_map, + &styles, + ); let available = taffy::Size { width: taffy::AvailableSpace::Definite(initial_cb_width), height: taffy::AvailableSpace::Definite(viewport.1), @@ -3851,11 +3933,11 @@ fn layout_dom_once( _node, ctx: Option<&mut usize>, _style: &taffy::Style| { - match ctx { + match ctx { Some(&mut idx) => engine.measure_taffy(idx, known, avail), - None => taffy::Size::ZERO, - } - }; + None => taffy::Size::ZERO, + } + }; // Table used-width pass. A grid table is built at width:auto, so // its final width has to be chosen the way CSS chooses a table's @@ -3920,294 +4002,309 @@ fn layout_dom_once( } } for &(tnode, dom, _) in group { - let Some(table_style) = styles.get(&dom) else { - continue; - }; - // A percentage-width table resolves against its container, so - // leave taffy's percentage handling in place. - let width_style = table_style.width; - if matches!(width_style, crate::Dimension::Percent(_)) { - continue; - } - let min_c = { - let _ = taffy_tree.compute_layout_with_measure( - tnode, - taffy::Size { width: taffy::AvailableSpace::MinContent, height: taffy::AvailableSpace::MaxContent }, - &mut measure, - ); - taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0) - }; - // A table can never be narrower than an unshrinkable - // fixed-width descendant. A Wikipedia infobox holds a - // ~267px image montage and a ~250px geologic-timeline - // widget, each with an explicit px width; taffy's grid - // min-content does not surface such a deep descendant's - // definite width up through the intervening flex/absolute - // boxes, leaving the table too narrow so those widgets - // overflow it. Floor min-content by the widest fixed child, - // matching CSS (a table's min-content is at least any - // fixed-width content it contains). - let min_c = min_c.max( - max_definite_table_content_width(tree, dom, &styles) - .unwrap_or(0.0), - ); - let max_c = { - let _ = taffy_tree.compute_layout_with_measure( - tnode, - taffy::Size { width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent }, - &mut measure, - ); - taffy_tree.layout(tnode).map(|l| l.size.width).unwrap_or(0.0) - }; - let inline_outer_edges = table_inline_outer_edges(table_style); - let preferred_outer = match width_style { - crate::Dimension::Px(w) - if table_style.box_sizing == crate::BoxSizing::ContentBox => - { - w + inline_outer_edges - } - crate::Dimension::Px(w) => w, - _ => max_c, - }; - let used_outer = match width_style { - // A definite table width is not clamped to its - // containing block. Like other fixed boxes it may - // overflow, while min-content can still make it wider. - crate::Dimension::Px(_) => preferred_outer.max(min_c), - _ => { - let available_outer = available_widths - .get(&tnode) - .copied() - .or_else(|| { - reliable_table_available_width( - tree, - dom, - &styles, - initial_cb_width, - ) - }) - .unwrap_or(initial_cb_width); - preferred_outer - .max(min_c) - .min(available_outer.max(min_c)) - } - }; - let used_declaration = if table_style.box_sizing == crate::BoxSizing::ContentBox - { - (used_outer - inline_outer_edges).max(0.0) - } else { - used_outer - }; - // Distribute the used track space proportionally between - // each column's own min-content and max-content width, the - // way CSS tables do, instead of letting the grid hand every - // auto track an equal share of the surplus (which over-widens - // narrow label columns and starves wide prose columns). - let cells: Vec<(taffy::NodeId, usize, usize)> = taffy_tree - .children(tnode) - .unwrap_or_default() - .into_iter() - .filter_map(|cell| { - let gc = taffy_tree.style(cell).ok()?.grid_column.clone(); - let col = match gc.start { - GridPlacement::Line(l) => (l.as_i16().max(1) as usize) - 1, - _ => return None, - }; - let span = match gc.end { - GridPlacement::Span(s) => (s as usize).max(1), - _ => 1, - }; - Some((cell, col, span)) - }) - .collect(); - let ncols = taffy_tree - .style(tnode) - .map(|s| s.grid_template_columns.len()) - .unwrap_or(0); - // Bound the extra per-cell measurement work; a pathologically - // large table keeps the grid's equal-share sizing (still renders). - if ncols > 0 && cells.len() <= 4096 { - // Measure every cell's own min-content and max-content width - // in isolation (the cell node is a valid subtree root; its - // grid placement only matters to its parent, so it lays out - // as a plain box here). - let mut measured: Vec<(usize, usize, f32, f32)> = - Vec::with_capacity(cells.len()); - for (cell, col, span) in &cells { - let _ = taffy_tree.compute_layout_with_measure( - *cell, - taffy::Size { width: taffy::AvailableSpace::MinContent, height: taffy::AvailableSpace::MaxContent }, - &mut measure, - ); - let cmin = taffy_tree.layout(*cell).map(|l| l.size.width).unwrap_or(0.0); - let _ = taffy_tree.compute_layout_with_measure( - *cell, - taffy::Size { width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent }, - &mut measure, - ); - let cmax = taffy_tree.layout(*cell).map(|l| l.size.width).unwrap_or(0.0); - measured.push((*col, *span, cmin, cmax.max(cmin))); - } - let mut col_min = vec![0.0f32; ncols]; - let mut col_max = vec![0.0f32; ncols]; - // Pass 1: single-column cells set each column's floor. - for &(col, span, cmin, cmax) in &measured { - if span == 1 && col < ncols { - col_min[col] = col_min[col].max(cmin); - col_max[col] = col_max[col].max(cmax); - } - } - // Pass 2: a spanning cell that needs more than its columns - // currently give grows them, splitting the shortfall evenly - // across the spanned columns (keeps colspan lining up). - for &(col, span, cmin, cmax) in &measured { - if span <= 1 || col >= ncols { - continue; - } - let end = (col + span).min(ncols); - let n = (end - col) as f32; - let cur_min: f32 = col_min[col..end].iter().sum(); - if cmin > cur_min { - let add = (cmin - cur_min) / n; - for w in &mut col_min[col..end] { - *w += add; - } - } - let cur_max: f32 = col_max[col..end].iter().sum(); - if cmax > cur_max { - let add = (cmax - cur_max) / n; - for w in &mut col_max[col..end] { - *w += add; - } - } - } - for j in 0..ncols { - if col_max[j] < col_min[j] { - col_max[j] = col_min[j]; - } - } - // Track space is the final table border box minus its - // actual border/padding, the two outer spacing bands, - // and the spacing between columns. Inferring this from - // min-content is wrong when a specified cell/column - // width already inflated that measurement: the fixed - // width gets counted as "overhead", starving later - // auto columns and forcing avoidable text wrapping. - let (horizontal_spacing, _) = table_spacing(table_style); - let interior_spacing = - horizontal_spacing * ncols.saturating_sub(1) as f32; - let target = - (used_outer - inline_outer_edges - interior_spacing) - .max(0.0); - // A specified length on a cell/column is a preferred - // contribution, not a min-content floor. Preserve the - // content minimum and raise only the preferred width so - // a constrained table can interpolate below the hint. - // Percentage tracks retain the existing definite-table - // behavior here; broader percent balancing is separate. - let specified_columns = ifc_items.table_cols.get(&tnode); - if let Some((spec_px, spec_pct)) = specified_columns { - for j in 0..ncols { - let fixed = spec_px - .get(j) - .copied() - .flatten(); - if let Some(w) = fixed { - let w = w.max(col_min[j]); - col_max[j] = w; - } else if let Some(w) = spec_pct - .get(j) - .copied() - .flatten() - .map(|percent| percent * target) - { - let w = w.max(col_min[j]); - col_min[j] = w; - col_max[j] = w; - } - } - } - let sum_min: f32 = col_min.iter().sum(); - let sum_max: f32 = col_max.iter().sum(); - let widths: Vec = if target <= sum_min { - col_min.clone() - } else if target >= sum_max { - // Surplus follows the table-layout priority: auto - // columns absorb it before fixed and percentage - // columns. This is important even when every - // column has min==max (a one-word auto cell still - // fills the remainder of a definite-width table). - let mut result = col_max.clone(); - let extra = target - sum_max; - let is_px = |j: usize| { - specified_columns - .and_then(|(px, _)| px.get(j)) - .copied() - .flatten() - .is_some() - }; - let is_pct = |j: usize| { - specified_columns - .and_then(|(_, pct)| pct.get(j)) - .copied() - .flatten() - .is_some() - }; - let mut candidates: Vec = (0..ncols) - .filter(|&j| !is_px(j) && !is_pct(j) && col_max[j] > 0.0) - .collect(); - if candidates.is_empty() { - candidates = (0..ncols) - .filter(|&j| !is_px(j) && !is_pct(j)) - .collect(); - } - if candidates.is_empty() { - candidates = (0..ncols).filter(|&j| is_px(j)).collect(); - } - if candidates.is_empty() { - candidates = (0..ncols).filter(|&j| is_pct(j)).collect(); - } - if candidates.is_empty() { - candidates = (0..ncols).collect(); - } - let weight_sum: f32 = - candidates.iter().map(|&j| col_max[j]).sum(); - for &j in &candidates { - let share = if weight_sum > 0.0 { - extra * col_max[j] / weight_sum - } else { - extra / candidates.len() as f32 - }; - result[j] += share; - } - result - } else { - let scale = (target - sum_min) / (sum_max - sum_min); - col_min - .iter() - .zip(&col_max) - .map(|(mn, mx)| mn + (mx - mn) * scale) - .collect() + let Some(table_style) = styles.get(&dom) else { + continue; }; - if let Ok(cur) = taffy_tree.style(tnode) { + // A percentage-width table resolves against its container, so + // leave taffy's percentage handling in place. + let width_style = table_style.width; + if matches!(width_style, crate::Dimension::Percent(_)) { + continue; + } + let min_c = { + let _ = taffy_tree.compute_layout_with_measure( + tnode, + taffy::Size { + width: taffy::AvailableSpace::MinContent, + height: taffy::AvailableSpace::MaxContent, + }, + &mut measure, + ); + taffy_tree + .layout(tnode) + .map(|l| l.size.width) + .unwrap_or(0.0) + }; + // A table can never be narrower than an unshrinkable + // fixed-width descendant. A Wikipedia infobox holds a + // ~267px image montage and a ~250px geologic-timeline + // widget, each with an explicit px width; taffy's grid + // min-content does not surface such a deep descendant's + // definite width up through the intervening flex/absolute + // boxes, leaving the table too narrow so those widgets + // overflow it. Floor min-content by the widest fixed child, + // matching CSS (a table's min-content is at least any + // fixed-width content it contains). + let min_c = min_c.max( + max_definite_table_content_width(tree, dom, &styles).unwrap_or(0.0), + ); + let max_c = { + let _ = taffy_tree.compute_layout_with_measure( + tnode, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + &mut measure, + ); + taffy_tree + .layout(tnode) + .map(|l| l.size.width) + .unwrap_or(0.0) + }; + let inline_outer_edges = table_inline_outer_edges(table_style); + let preferred_outer = match width_style { + crate::Dimension::Px(w) + if table_style.box_sizing == crate::BoxSizing::ContentBox => + { + w + inline_outer_edges + } + crate::Dimension::Px(w) => w, + _ => max_c, + }; + let used_outer = match width_style { + // A definite table width is not clamped to its + // containing block. Like other fixed boxes it may + // overflow, while min-content can still make it wider. + crate::Dimension::Px(_) => preferred_outer.max(min_c), + _ => { + let available_outer = available_widths + .get(&tnode) + .copied() + .or_else(|| { + reliable_table_available_width( + tree, + dom, + &styles, + initial_cb_width, + ) + }) + .unwrap_or(initial_cb_width); + preferred_outer.max(min_c).min(available_outer.max(min_c)) + } + }; + let used_declaration = + if table_style.box_sizing == crate::BoxSizing::ContentBox { + (used_outer - inline_outer_edges).max(0.0) + } else { + used_outer + }; + // Distribute the used track space proportionally between + // each column's own min-content and max-content width, the + // way CSS tables do, instead of letting the grid hand every + // auto track an equal share of the surplus (which over-widens + // narrow label columns and starves wide prose columns). + let cells: Vec<(taffy::NodeId, usize, usize)> = taffy_tree + .children(tnode) + .unwrap_or_default() + .into_iter() + .filter_map(|cell| { + let gc = taffy_tree.style(cell).ok()?.grid_column.clone(); + let col = match gc.start { + GridPlacement::Line(l) => (l.as_i16().max(1) as usize) - 1, + _ => return None, + }; + let span = match gc.end { + GridPlacement::Span(s) => (s as usize).max(1), + _ => 1, + }; + Some((cell, col, span)) + }) + .collect(); + let ncols = taffy_tree + .style(tnode) + .map(|s| s.grid_template_columns.len()) + .unwrap_or(0); + // Bound the extra per-cell measurement work; a pathologically + // large table keeps the grid's equal-share sizing (still renders). + if ncols > 0 && cells.len() <= 4096 { + // Measure every cell's own min-content and max-content width + // in isolation (the cell node is a valid subtree root; its + // grid placement only matters to its parent, so it lays out + // as a plain box here). + let mut measured: Vec<(usize, usize, f32, f32)> = + Vec::with_capacity(cells.len()); + for (cell, col, span) in &cells { + let _ = taffy_tree.compute_layout_with_measure( + *cell, + taffy::Size { + width: taffy::AvailableSpace::MinContent, + height: taffy::AvailableSpace::MaxContent, + }, + &mut measure, + ); + let cmin = taffy_tree + .layout(*cell) + .map(|l| l.size.width) + .unwrap_or(0.0); + let _ = taffy_tree.compute_layout_with_measure( + *cell, + taffy::Size { + width: taffy::AvailableSpace::MaxContent, + height: taffy::AvailableSpace::MaxContent, + }, + &mut measure, + ); + let cmax = taffy_tree + .layout(*cell) + .map(|l| l.size.width) + .unwrap_or(0.0); + measured.push((*col, *span, cmin, cmax.max(cmin))); + } + let mut col_min = vec![0.0f32; ncols]; + let mut col_max = vec![0.0f32; ncols]; + // Pass 1: single-column cells set each column's floor. + for &(col, span, cmin, cmax) in &measured { + if span == 1 && col < ncols { + col_min[col] = col_min[col].max(cmin); + col_max[col] = col_max[col].max(cmax); + } + } + // Pass 2: a spanning cell that needs more than its columns + // currently give grows them, splitting the shortfall evenly + // across the spanned columns (keeps colspan lining up). + for &(col, span, cmin, cmax) in &measured { + if span <= 1 || col >= ncols { + continue; + } + let end = (col + span).min(ncols); + let n = (end - col) as f32; + let cur_min: f32 = col_min[col..end].iter().sum(); + if cmin > cur_min { + let add = (cmin - cur_min) / n; + for w in &mut col_min[col..end] { + *w += add; + } + } + let cur_max: f32 = col_max[col..end].iter().sum(); + if cmax > cur_max { + let add = (cmax - cur_max) / n; + for w in &mut col_max[col..end] { + *w += add; + } + } + } + for j in 0..ncols { + if col_max[j] < col_min[j] { + col_max[j] = col_min[j]; + } + } + // Track space is the final table border box minus its + // actual border/padding, the two outer spacing bands, + // and the spacing between columns. Inferring this from + // min-content is wrong when a specified cell/column + // width already inflated that measurement: the fixed + // width gets counted as "overhead", starving later + // auto columns and forcing avoidable text wrapping. + let (horizontal_spacing, _) = table_spacing(table_style); + let interior_spacing = + horizontal_spacing * ncols.saturating_sub(1) as f32; + let target = + (used_outer - inline_outer_edges - interior_spacing).max(0.0); + // A specified length on a cell/column is a preferred + // contribution, not a min-content floor. Preserve the + // content minimum and raise only the preferred width so + // a constrained table can interpolate below the hint. + // Percentage tracks retain the existing definite-table + // behavior here; broader percent balancing is separate. + let specified_columns = ifc_items.table_cols.get(&tnode); + if let Some((spec_px, spec_pct)) = specified_columns { + for j in 0..ncols { + let fixed = spec_px.get(j).copied().flatten(); + if let Some(w) = fixed { + let w = w.max(col_min[j]); + col_max[j] = w; + } else if let Some(w) = spec_pct + .get(j) + .copied() + .flatten() + .map(|percent| percent * target) + { + let w = w.max(col_min[j]); + col_min[j] = w; + col_max[j] = w; + } + } + } + let sum_min: f32 = col_min.iter().sum(); + let sum_max: f32 = col_max.iter().sum(); + let widths: Vec = if target <= sum_min { + col_min.clone() + } else if target >= sum_max { + // Surplus follows the table-layout priority: auto + // columns absorb it before fixed and percentage + // columns. This is important even when every + // column has min==max (a one-word auto cell still + // fills the remainder of a definite-width table). + let mut result = col_max.clone(); + let extra = target - sum_max; + let is_px = |j: usize| { + specified_columns + .and_then(|(px, _)| px.get(j)) + .copied() + .flatten() + .is_some() + }; + let is_pct = |j: usize| { + specified_columns + .and_then(|(_, pct)| pct.get(j)) + .copied() + .flatten() + .is_some() + }; + let mut candidates: Vec = (0..ncols) + .filter(|&j| !is_px(j) && !is_pct(j) && col_max[j] > 0.0) + .collect(); + if candidates.is_empty() { + candidates = + (0..ncols).filter(|&j| !is_px(j) && !is_pct(j)).collect(); + } + if candidates.is_empty() { + candidates = (0..ncols).filter(|&j| is_px(j)).collect(); + } + if candidates.is_empty() { + candidates = (0..ncols).filter(|&j| is_pct(j)).collect(); + } + if candidates.is_empty() { + candidates = (0..ncols).collect(); + } + let weight_sum: f32 = candidates.iter().map(|&j| col_max[j]).sum(); + for &j in &candidates { + let share = if weight_sum > 0.0 { + extra * col_max[j] / weight_sum + } else { + extra / candidates.len() as f32 + }; + result[j] += share; + } + result + } else { + let scale = (target - sum_min) / (sum_max - sum_min); + col_min + .iter() + .zip(&col_max) + .map(|(mn, mx)| mn + (mx - mn) * scale) + .collect() + }; + if let Ok(cur) = taffy_tree.style(tnode) { + let mut s = cur.clone(); + s.size.width = length(used_declaration); + s.grid_template_columns = widths + .iter() + .map(|w| { + taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::length(*w), + max: taffy::MaxTrackSizingFunction::length(*w), + }) + }) + .collect(); + let _ = taffy_tree.set_style(tnode, s); + } + } else if let Ok(cur) = taffy_tree.style(tnode) { let mut s = cur.clone(); s.size.width = length(used_declaration); - s.grid_template_columns = widths - .iter() - .map(|w| { - taffy::GridTemplateComponent::Single(taffy::MinMax { - min: taffy::MinTrackSizingFunction::length(*w), - max: taffy::MaxTrackSizingFunction::length(*w), - }) - }) - .collect(); let _ = taffy_tree.set_style(tnode, s); } - } else if let Ok(cur) = taffy_tree.style(tnode) { - let mut s = cur.clone(); - s.size.width = length(used_declaration); - let _ = taffy_tree.set_style(tnode, s); - } } table_index = group_end; } @@ -4260,43 +4357,20 @@ fn layout_dom_once( vw, vh, ) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } - if apply_float_continuations( - tree, - &mut taffy_tree, - &id_map, - &styles, - &ifc_items, - ) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + if apply_float_continuations(tree, &mut taffy_tree, &id_map, &styles, &ifc_items) { + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } - if apply_table_row_geometry( - &mut taffy_tree, - &id_map, - &styles, - &ifc_items, - ) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + if apply_table_row_geometry(&mut taffy_tree, &id_map, &styles, &ifc_items) { + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } if apply_full_span_column_subgrids( tree, @@ -4310,29 +4384,18 @@ fn layout_dom_once( width: taffy::AvailableSpace::MaxContent, height: taffy::AvailableSpace::MaxContent, }, - &mut measure, + &mut measure, ) .ok()?; tree.layout(node).ok().map(|layout| layout.size.width) }, ) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } - if apply_table_cell_block_alignment( - tree, - &mut taffy_tree, - &id_map, - &styles, - ) { - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + if apply_table_cell_block_alignment(tree, &mut taffy_tree, &id_map, &styles) { + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } // A fully-auto positioned axis uses the box's static position // in its original formatting context. Harvest that coordinate @@ -4347,11 +4410,8 @@ fn layout_dom_once( &mut taffy_tree, &static_position_candidates, ); - let _ = taffy_tree.compute_layout_with_measure( - taffy_root, - available, - &mut measure, - ); + let _ = + taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); } } #[cfg(not(feature = "paint"))] @@ -4405,21 +4465,10 @@ fn layout_dom_once( if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) { let _ = taffy_tree.compute_layout(taffy_root, available); } - if apply_float_continuations( - tree, - &mut taffy_tree, - &id_map, - &styles, - &ifc_items, - ) { + if apply_float_continuations(tree, &mut taffy_tree, &id_map, &styles, &ifc_items) { let _ = taffy_tree.compute_layout(taffy_root, available); } - if apply_table_row_geometry( - &mut taffy_tree, - &id_map, - &styles, - &ifc_items, - ) { + if apply_table_row_geometry(&mut taffy_tree, &id_map, &styles, &ifc_items) { let _ = taffy_tree.compute_layout(taffy_root, available); } if apply_full_span_column_subgrids( @@ -4441,12 +4490,7 @@ fn layout_dom_once( ) { let _ = taffy_tree.compute_layout(taffy_root, available); } - if apply_table_cell_block_alignment( - tree, - &mut taffy_tree, - &id_map, - &styles, - ) { + if apply_table_cell_block_alignment(tree, &mut taffy_tree, &id_map, &styles) { let _ = taffy_tree.compute_layout(taffy_root, available); } // Keep the no-paint geometry path in the same final-position @@ -4488,8 +4532,7 @@ fn layout_dom_once( &generated_nodes, &mut generated_rects, ); - inline_fragments = - synthesize_ordinary_inline_fragments(&mut rects, &styles, &engine); + inline_fragments = synthesize_ordinary_inline_fragments(&mut rects, &styles, &engine); synthesize_row_rects(tree, &mut rects); } } @@ -4531,7 +4574,9 @@ fn layout_dom_once( // neighbor) aligns its content per vertical-align; the pure-text // leaf path has no inner box to align, so shift the pinned origin // by the leftover space instead. - if let Some(va @ (crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom)) = style.vertical_align { + if let Some(va @ (crate::VerticalAlign::Middle | crate::VerticalAlign::Bottom)) = + style.vertical_align + { let (_, th) = engine.measure(idx, Some(cw)); let content_h = rect.height - style.padding.top @@ -4539,7 +4584,11 @@ fn layout_dom_once( - style.border.top - style.border.bottom; let free = (content_h - th).max(0.0); - origin.1 += if va == crate::VerticalAlign::Middle { free / 2.0 } else { free }; + origin.1 += if va == crate::VerticalAlign::Middle { + free / 2.0 + } else { + free + }; } // The shaped text is this container's own content, so an // `overflow: hidden` on the container clips it (this is what keeps @@ -4547,7 +4596,7 @@ fn layout_dom_once( // now that the text is one leaf rather than clipped word boxes). // Clips live in screen space; the container's own box joins the // chain shifted by its accumulated translate. - let inherited = clip_rects.get(nid).copied().flatten(); + let inherited = clip_rects.get(nid).cloned().flatten(); let clip = if style.overflow_hidden { let (tx, ty) = translates.get(nid).copied().unwrap_or((0.0, 0.0)); let own = OverflowClip::for_box(rect, style, tx, ty); @@ -4567,7 +4616,7 @@ fn layout_dom_once( // block (its inherited chain, plus its own overflow like any child). #[cfg(feature = "paint")] for (parent, items) in &ifc_items.runs { - let inherited = clip_rects.get(parent).copied().flatten(); + let inherited = clip_rects.get(parent).cloned().flatten(); let clip = match (styles.get(parent), rects.get(parent)) { (Some(style), Some(prect)) if style.overflow_hidden => { let (tx, ty) = translates.get(parent).copied().unwrap_or((0.0, 0.0)); @@ -4594,7 +4643,7 @@ fn layout_dom_once( for (text_node, items) in &ifc_items.word_items { let clip = clip_rects .get(text_node) - .copied() + .cloned() .flatten() .map(|clip| clip.viewport_rect(viewport)); for &idx in items { @@ -4654,14 +4703,8 @@ fn layout_dom_once( ); } for (parent, items) in &ifc_items.runs { - let clip = shaped_item_clip( - *parent, - &rects, - &styles, - &clip_rects, - &translates, - viewport, - ); + let clip = + shaped_item_clip(*parent, &rects, &styles, &clip_rects, &translates, viewport); for &idx in items { engine.set_clip(idx, clip); } @@ -4765,7 +4808,7 @@ fn shaped_item_clip( translates: &HashMap, viewport: (f32, f32), ) -> Option { - let inherited = clip_rects.get(&owner).copied().flatten(); + let inherited = clip_rects.get(&owner).cloned().flatten(); let clip = match (styles.get(&owner), rects.get(&owner)) { (Some(style), Some(rect)) if style.overflow_hidden => { let (tx, ty) = translates.get(&owner).copied().unwrap_or((0.0, 0.0)); @@ -4930,10 +4973,7 @@ fn folded_inline_relative_offset( Some(offset) } -fn container_snapshot( - tree: &DomTree, - layout: &DomLayout, -) -> crate::css::ContainerSnapshot { +fn container_snapshot(tree: &DomTree, layout: &DomLayout) -> crate::css::ContainerSnapshot { let root_font_size = tree .descendants(tree.document()) .into_iter() @@ -5006,7 +5046,10 @@ fn container_snapshot( /// when there is no surplus width to distribute in the first place. fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap) { let is_tag = |id: NodeId, tags: &[&str]| -> bool { - match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) { + match tree + .get_node(id) + .and_then(|n| n.as_element().map(|e| e.local.to_string())) + { Some(local) => tags.contains(&local.as_str()), None => false, } @@ -5017,7 +5060,10 @@ fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap`'s own scope. fn propagate_border_spacing(tree: &DomTree, styles: &mut HashMap) { fn local_name(tree: &DomTree, id: NodeId) -> Option { - tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) + tree.get_node(id) + .and_then(|n| n.as_element().map(|e| e.local.to_string())) } - fn apply_to_rows(tree: &DomTree, id: NodeId, h: f32, v: f32, styles: &mut HashMap) { + fn apply_to_rows( + tree: &DomTree, + id: NodeId, + h: f32, + v: f32, + styles: &mut HashMap, + ) { for cid in tree.children(id) { if local_name(tree, cid).as_deref() == Some("table") { continue; @@ -5119,8 +5172,12 @@ fn resolve_grid_areas( } for cid in rendered_children(tree, id) { - let Some(cstyle) = styles.get_mut(&cid) else { continue }; - let Some(name) = cstyle.grid_area_name.clone() else { continue }; + let Some(cstyle) = styles.get_mut(&cid) else { + continue; + }; + let Some(name) = cstyle.grid_area_name.clone() else { + continue; + }; if let Some(&(r0, r1, c0, c1)) = spans.get(&name) { use taffy::style_helpers::line; cstyle.grid_row = Some(taffy::Line { @@ -5139,7 +5196,9 @@ fn resolve_grid_areas( // values that reference a line name against this container's maps. if col_lines.is_some() || row_lines.is_some() { for cid in rendered_children(tree, id) { - let Some(cstyle) = styles.get_mut(&cid) else { continue }; + let Some(cstyle) = styles.get_mut(&cid) else { + continue; + }; if let (Some(raw), Some(map)) = (cstyle.grid_column_raw.clone(), &col_lines) { if let Some(l) = resolve_named_placement(&raw, map) { cstyle.grid_column = Some(l); @@ -5187,9 +5246,9 @@ fn is_full_span_column_subgrid(style: &crate::LayoutStyle) -> bool { || style.overflow_hidden || style.position == Some(taffy::Position::Absolute) || style.width != crate::Dimension::Auto - || style - .justify_self - .is_some_and(|value| value.resolve_normal(taffy::AlignSelf::STRETCH) != taffy::AlignSelf::STRETCH) + || style.justify_self.is_some_and(|value| { + value.resolve_normal(taffy::AlignSelf::STRETCH) != taffy::AlignSelf::STRETCH + }) || style.margin_auto[1] || style.margin_auto[3] { @@ -5255,7 +5314,12 @@ fn collect_column_subgrid_descendants( let start_mbp = start_mbp + style.margin.left + style.border.left + style.padding.left; let end_mbp = end_mbp + style.margin.right + style.border.right + style.padding.right; let gap = style.column_gap.unwrap_or(0.0); - wrappers.push(ColumnSubgridWrapper { node, gap, start_mbp, end_mbp }); + wrappers.push(ColumnSubgridWrapper { + node, + gap, + start_mbp, + end_mbp, + }); let children: Vec = tree .children(dom) @@ -5295,7 +5359,10 @@ fn collect_column_subgrid_descendants( } let flow = style.grid_auto_flow.unwrap_or(taffy::GridAutoFlow::Row); - if !matches!(flow, taffy::GridAutoFlow::Row | taffy::GridAutoFlow::RowDense) { + if !matches!( + flow, + taffy::GridAutoFlow::Row | taffy::GridAutoFlow::RowDense + ) { return false; } for (index, child) in children.into_iter().enumerate() { @@ -5375,8 +5442,13 @@ where for (&dom, style) in styles { if style.display != crate::Display::Grid || style.grid_template_columns_subgrid - || !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_)) - || style.justify_content.is_some_and(|value| value != taffy::JustifyContent::STRETCH) + || !matches!( + style.width, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ) + || style + .justify_content + .is_some_and(|value| value != taffy::JustifyContent::STRETCH) { continue; } @@ -5395,7 +5467,11 @@ where let direct: Vec = tree .children(dom) .into_iter() - .filter(|child| styles.get(child).is_some_and(|style| style.display != crate::Display::None)) + .filter(|child| { + styles + .get(child) + .is_some_and(|style| style.display != crate::Display::None) + }) .collect(); if direct.is_empty() || !direct @@ -5507,7 +5583,9 @@ where } copied[0] -= wrapper.start_mbp; copied[plan.track_count - 1] -= wrapper.end_mbp; - if copied.iter().any(|width| !width.is_finite() || *width < 0.0) + if copied + .iter() + .any(|width| !width.is_finite() || *width < 0.0) || taffy_tree.style(wrapper.node).is_err() { valid = false; @@ -5571,9 +5649,15 @@ fn resolve_named_placement( .get(&format!("{name}-end")) .map(|&e| line(e)) .unwrap_or(taffy::GridPlacement::Auto); - return Some(taffy::Line { start: line(s), end }); + return Some(taffy::Line { + start: line(s), + end, + }); } - map.get(name).map(|&s| taffy::Line { start: line(s), end: taffy::GridPlacement::Auto }) + map.get(name).map(|&s| taffy::Line { + start: line(s), + end: taffy::GridPlacement::Auto, + }) } } @@ -5593,7 +5677,12 @@ fn compute_absolute_rects( if let Ok(layout) = taffy_tree.layout(taffy_id) { let x = abs_x + layout.location.x; let y = abs_y + layout.location.y; - let rect = Rect { x, y, width: layout.size.width, height: layout.size.height }; + let rect = Rect { + x, + y, + width: layout.size.width, + height: layout.size.height, + }; if let Some(dom_id) = id_map.get(&taffy_id) { rects.insert(*dom_id, rect); @@ -5609,7 +5698,10 @@ fn compute_absolute_rects( // A word leaf's dom_id is its owning text node, shared by every other // word from the same node, so this appends rather than overwrites. if let Some((text_dom_id, word)) = words.get(&taffy_id) { - text_runs.entry(*text_dom_id).or_default().push((rect, word.clone())); + text_runs + .entry(*text_dom_id) + .or_default() + .push((rect, word.clone())); } if let Ok(children) = taffy_tree.children(taffy_id) { @@ -5656,14 +5748,18 @@ fn reparent_inset_positioned_nodes( id_map: &HashMap, styles: &HashMap, ) -> Vec { - let reverse: HashMap = - id_map.iter().map(|(&taffy_id, &dom_id)| (dom_id, taffy_id)).collect(); + let reverse: HashMap = id_map + .iter() + .map(|(&taffy_id, &dom_id)| (dom_id, taffy_id)) + .collect(); let mut nearest_abs_cb_for_children: HashMap = HashMap::new(); let mut nearest_fixed_cb_for_children: HashMap = HashMap::new(); let mut static_candidates = Vec::new(); for dom_id in tree.descendants(tree.document()) { - let Some(style) = styles.get(&dom_id) else { continue }; + let Some(style) = styles.get(&dom_id) else { + continue; + }; let parent = tree.get_node(dom_id).and_then(|node| node.parent); let inherited_abs_cb = parent .and_then(|id| nearest_abs_cb_for_children.get(&id).copied()) @@ -5696,13 +5792,17 @@ fn reparent_inset_positioned_nodes( } let has_block_inset = style.inset[0].is_some() || style.inset[2].is_some(); let has_inline_inset = style.inset[1].is_some() || style.inset[3].is_some(); - let Some(&child) = reverse.get(&dom_id) else { continue }; + let Some(&child) = reverse.get(&dom_id) else { + continue; + }; let target = if style.position_fixed { inherited_fixed_cb } else { inherited_abs_cb }; - let Some(current) = taffy_tree.parent(child) else { continue }; + let Some(current) = taffy_tree.parent(child) else { + continue; + }; if current == target { continue; } @@ -5722,10 +5822,7 @@ fn reparent_inset_positioned_nodes( static_candidates } -fn taffy_global_origin( - taffy_tree: &TaffyTree, - node: taffy::NodeId, -) -> Option<(f32, f32)> { +fn taffy_global_origin(taffy_tree: &TaffyTree, node: taffy::NodeId) -> Option<(f32, f32)> { let mut current = Some(node); let mut x = 0.0; let mut y = 0.0; @@ -5782,10 +5879,7 @@ fn narrow_node_to_float_band( let Some(&rect) = preliminary_rects.get(&node) else { return false; }; - if rect.y >= band.bottom - || rect.y + rect.height <= band.top - || rect.width <= 0.0 - { + if rect.y >= band.bottom || rect.y + rect.height <= band.top || rect.width <= 0.0 { return false; } let Ok(current) = taffy_tree.style(node) else { @@ -5826,8 +5920,7 @@ fn narrow_node_to_float_band( narrowed.size.width = taffy::Dimension::length(specified); narrowed.max_size.width = taffy::Dimension::length(specified); if left_shift > 0.0 { - narrowed.margin.left = - taffy::LengthPercentageAuto::length(layout.margin.left + left_shift); + narrowed.margin.left = taffy::LengthPercentageAuto::length(layout.margin.left + left_shift); } taffy_tree.set_style(node, narrowed).is_ok() } @@ -5905,10 +5998,7 @@ fn narrow_intersecting_descendants( styles.get(child).map_or(false, |child_style| { child_style.display != crate::Display::None && child_style.float.is_none() - && !matches!( - child_style.position, - Some(taffy::Position::Absolute) - ) + && !matches!(child_style.position, Some(taffy::Position::Absolute)) }) }) .collect(); @@ -5916,12 +6006,7 @@ fn narrow_intersecting_descendants( || ifc_items.whole.contains_key(&id) || in_flow_element_children.is_empty() { - return narrow_node_to_float_band( - taffy_tree, - node, - preliminary_rects, - band, - ); + return narrow_node_to_float_band(taffy_tree, node, preliminary_rects, band); } let mut changed = false; @@ -5957,13 +6042,7 @@ fn apply_float_continuations( return false; }; let mut preliminary_rects = HashMap::with_capacity(id_map.len()); - collect_taffy_global_rects( - taffy_tree, - root, - 0.0, - 0.0, - &mut preliminary_rects, - ); + collect_taffy_global_rects(taffy_tree, root, 0.0, 0.0, &mut preliminary_rects); let mut changed = false; for continuation in &ifc_items.float_continuations { let Some(&float_rect) = preliminary_rects.get(&continuation.float) else { @@ -5992,11 +6071,9 @@ fn apply_float_continuations( // blocks so only the leaf/block bands that actually intersect the // float are narrowed; later siblings below the float stay full width. let mut current = continuation.owner; - while let Some(parent) = tree.get_node(current).and_then(|node| node.parent) - { + while let Some(parent) = tree.get_node(current).and_then(|node| node.parent) { let siblings = rendered_children(tree, parent); - let Some(index) = siblings.iter().position(|candidate| *candidate == current) - else { + let Some(index) = siblings.iter().position(|candidate| *candidate == current) else { break; }; for sibling in &siblings[index + 1..] { @@ -6023,11 +6100,11 @@ fn apply_float_continuations( { if let Some(&bfc_node) = reverse.get(&parent) { changed |= grow_bfc_to_float_bottom( - taffy_tree, - bfc_node, - &preliminary_rects, - band.bottom, - ); + taffy_tree, + bfc_node, + &preliminary_rects, + band.bottom, + ); } } break; @@ -6061,8 +6138,7 @@ fn apply_table_row_geometry( .collect(); let mut cells = Vec::new(); for cell in taffy_tree.children(table_node).unwrap_or_default() { - let (Ok(cell_style), Ok(layout)) = - (taffy_tree.style(cell), taffy_tree.layout(cell)) + let (Ok(cell_style), Ok(layout)) = (taffy_tree.style(cell), taffy_tree.layout(cell)) else { continue; }; @@ -6089,7 +6165,8 @@ fn apply_table_row_geometry( if let Some(style) = styles.get(dom_id) { if let crate::Dimension::Px(height) = style.height { let specified = if style.box_sizing == crate::BoxSizing::ContentBox { - height + style.padding.top + height + + style.padding.top + style.padding.bottom + style.border.top + style.border.bottom @@ -6129,8 +6206,8 @@ fn apply_table_row_geometry( continue; } let end = row + span; - let current: f32 = row_heights[row..end].iter().sum::() - + row_gap * span.saturating_sub(1) as f32; + let current: f32 = + row_heights[row..end].iter().sum::() + row_gap * span.saturating_sub(1) as f32; let extra = natural - current; if extra <= 0.0 { continue; @@ -6172,8 +6249,7 @@ fn apply_table_row_geometry( if targets.is_empty() { targets = (0..nrows).collect(); } - let weight: f32 = - targets.iter().map(|&index| row_heights[index]).sum(); + let weight: f32 = targets.iter().map(|&index| row_heights[index]).sum(); for &index in &targets { let share = if weight > 0.0 { extra * row_heights[index] / weight @@ -6224,10 +6300,7 @@ fn apply_table_row_geometry( /// source-ordered greedy fill needs no more than the requested column count. /// This is O(24 * children) per multicol container and does not reshape or /// rebuild descendants during the search. -fn apply_multicol_balance( - taffy_tree: &mut TaffyTree, - multicol: &[MulticolBuild], -) -> bool { +fn apply_multicol_balance(taffy_tree: &mut TaffyTree, multicol: &[MulticolBuild]) -> bool { let mut changed = false; for set in multicol { let column_count = set.columns.len(); @@ -6239,8 +6312,7 @@ fn apply_multicol_balance( .iter() .map(|child| { taffy_tree.layout(*child).map_or(0.0, |layout| { - (layout.size.height + layout.margin.top + layout.margin.bottom) - .max(0.0) + (layout.size.height + layout.margin.top + layout.margin.bottom).max(0.0) }) }) .collect(); @@ -6335,7 +6407,11 @@ fn apply_table_cell_block_alignment( matches!(element.local.as_ref(), "td" | "th") }) }); - if !is_cell || taffy_tree.children(node).map_or(true, |children| children.is_empty()) { + if !is_cell + || taffy_tree + .children(node) + .map_or(true, |children| children.is_empty()) + { // Pure-text cells are aligned after shaping in the text finalize // path, where their actual line box height is available. continue; @@ -6347,10 +6423,8 @@ fn apply_table_cell_block_alignment( continue; } let mut aligned = current.clone(); - let border_padding = layout.border.top - + layout.border.bottom - + layout.padding.top - + layout.padding.bottom; + let border_padding = + layout.border.top + layout.border.bottom + layout.padding.top + layout.padding.bottom; let declared_height = if aligned.box_sizing == taffy::BoxSizing::ContentBox { (layout.size.height - border_padding).max(0.0) } else { @@ -6442,8 +6516,7 @@ fn repair_intrinsic_column_flex_negative_margins( if !has_negative_main_margin || item_count == 0 { continue; } - content_height += - style.row_gap.unwrap_or(0.0) * item_count.saturating_sub(1) as f32; + content_height += style.row_gap.unwrap_or(0.0) * item_count.saturating_sub(1) as f32; content_height = content_height.max(0.0); let Ok(layout) = taffy_tree.layout(node) else { @@ -6487,13 +6560,23 @@ fn resolve_static_positions_and_reparent( // coordinates. let mut resolved = Vec::with_capacity(candidates.len()); for candidate in candidates { - let Some(child_origin) = taffy_global_origin(taffy_tree, candidate.child) else { continue }; - let Some(target_origin) = taffy_global_origin(taffy_tree, candidate.target) else { continue }; - let Ok(child_layout) = taffy_tree.layout(candidate.child) else { continue }; + let Some(child_origin) = taffy_global_origin(taffy_tree, candidate.child) else { + continue; + }; + let Some(target_origin) = taffy_global_origin(taffy_tree, candidate.target) else { + continue; + }; + let Ok(child_layout) = taffy_tree.layout(candidate.child) else { + continue; + }; let child_margin = child_layout.margin; - let Ok(target_layout) = taffy_tree.layout(candidate.target) else { continue }; + let Ok(target_layout) = taffy_tree.layout(candidate.target) else { + continue; + }; let target_border = target_layout.border; - let Ok(current_style) = taffy_tree.style(candidate.child) else { continue }; + let Ok(current_style) = taffy_tree.style(candidate.child) else { + continue; + }; let mut style = current_style.clone(); if candidate.inline_axis { @@ -6508,11 +6591,16 @@ fn resolve_static_positions_and_reparent( } for (candidate, style) in resolved { - let Some(current) = taffy_tree.parent(candidate.child) else { continue }; + let Some(current) = taffy_tree.parent(candidate.child) else { + continue; + }; if taffy_tree.remove_child(current, candidate.child).is_err() { continue; } - if taffy_tree.add_child(candidate.target, candidate.child).is_err() { + if taffy_tree + .add_child(candidate.target, candidate.child) + .is_err() + { let _ = taffy_tree.add_child(current, candidate.child); continue; } @@ -6562,9 +6650,15 @@ pub(crate) fn rendered_children(tree: &DomTree, id: NodeId) -> Vec { /// non-whitespace text node, or an element whose resolved display is /// `Inline`)? Used to decide whether a block container needs the flex-row-wrap /// approximation of an inline formatting context. -fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap) -> bool { +fn has_inline_content( + tree: &DomTree, + id: NodeId, + styles: &HashMap, +) -> bool { rendered_children(tree, id).into_iter().any(|cid| { - let Some(node) = tree.get_node(cid) else { return false }; + let Some(node) = tree.get_node(cid) else { + return false; + }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(), _ => styles @@ -6631,10 +6725,9 @@ fn blockify_layout_children( /// cannot reach them. fn blockify_generated_pseudos(styles: &mut HashMap) { for host in styles.values_mut() { - let host_is_item_container = matches!( - host.display, - crate::Display::Flex | crate::Display::Grid - ) && !host.internal_flex_container; + let host_is_item_container = + matches!(host.display, crate::Display::Flex | crate::Display::Grid) + && !host.internal_flex_container; for pseudo in [host.before_pseudo.as_mut(), host.after_pseudo.as_mut()] .into_iter() .flatten() @@ -6673,15 +6766,7 @@ fn build_any( .map(|n| matches!(n.data, obscura_dom::tree::NodeData::Text { .. })) .unwrap_or(false); if is_text { - return build_text_words( - tree, - id, - taffy_tree, - styles, - words, - engine, - ifc_items, - ); + return build_text_words(tree, id, taffy_tree, styles, words, engine, ifc_items); } // `display: contents` removes the element's own box; its children lay out as // if they were direct children of its parent (CSS Display 3). Splice them @@ -6697,7 +6782,11 @@ fn build_any( if splices_children { return rendered_children(tree, id) .into_iter() - .flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) + .flat_map(|cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) .collect(); } if is_flattenable_inline(tree, id, styles) { @@ -6719,10 +6808,16 @@ fn build_any( // words become flat siblings that wrap only at the real block level. return rendered_children(tree, id) .into_iter() - .flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) + .flat_map(|cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) .collect(); } - let Some(inner) = build(tree, id, taffy_tree, id_map, words, engine, ifc_items, styles) else { + let Some(inner) = build( + tree, id, taffy_tree, id_map, words, engine, ifc_items, styles, + ) else { return Vec::new(); }; let inline_align = styles.get(&id).and_then(|style| { @@ -6867,14 +6962,7 @@ fn build_flex_grid_children( } for &text in run { children.extend(build_any( - tree, - text, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, text, taffy_tree, id_map, words, engine, ifc_items, styles, )); } } @@ -6886,8 +6974,14 @@ fn build_flex_grid_children( /// it an independent (and, for wrapping auto-width containers, buggy) flex /// context? Covers the dominant real-world case: ``/``/``/etc. /// wrapping plain text with no inline styling. -fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap) -> bool { - let Some(node) = tree.get_node(id) else { return false }; +fn is_flattenable_inline( + tree: &DomTree, + id: NodeId, + styles: &HashMap, +) -> bool { + let Some(node) = tree.get_node(id) else { + return false; + }; let Some(element) = node.as_element() else { return false; }; @@ -6896,7 +6990,9 @@ fn is_flattenable_inline(tree: &DomTree, id: NodeId, styles: &HashMap Vec { - let Some(node) = tree.get_node(id) else { return Vec::new() }; - let obscura_dom::tree::NodeData::Text { contents } = &node.data else { return Vec::new() }; + let Some(node) = tree.get_node(id) else { + return Vec::new(); + }; + let obscura_dom::tree::NodeData::Text { contents } = &node.data else { + return Vec::new(); + }; let mut display_text = String::new(); let mut in_space = false; @@ -6957,9 +7057,7 @@ fn build_text_words( is_bold = crate::style::used_font_weight(p_style) >= 600; family = p_style.font_family.as_deref(); line_height = crate::inline::used_line_height(p_style); - transform = p_style - .text_transform - .unwrap_or(crate::TextTransform::None); + transform = p_style.text_transform.unwrap_or(crate::TextTransform::None); letter_spacing = p_style.letter_spacing.unwrap_or(0.0); } } @@ -7040,9 +7138,7 @@ fn build_shaped_word_leaves( source_id, transform_word_leaf_text( &token, - style - .text_transform - .unwrap_or(crate::TextTransform::None), + style.text_transform.unwrap_or(crate::TextTransform::None), ), ), ); @@ -7111,7 +7207,10 @@ fn build_word_leaves( line_height.max(0.0) }; let taffy_style = taffy::Style { - size: taffy::Size { width: taffy::Dimension::length(width), height: taffy::Dimension::length(height) }, + size: taffy::Size { + width: taffy::Dimension::length(width), + height: taffy::Dimension::length(height), + }, ..Default::default() }; let taffy_id = taffy_tree.new_leaf(taffy_style).ok()?; @@ -7263,7 +7362,10 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap) { let mut sections = Vec::new(); let mut table_inline: HashMap = HashMap::new(); for id in tree.descendants(tree.document()) { - let local = match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) { + let local = match tree + .get_node(id) + .and_then(|n| n.as_element().map(|e| e.local.to_string())) + { Some(l) => l, None => continue, }; @@ -7302,7 +7404,10 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap) { for cell in tree.children(id) { let is_cell = tree .get_node(cell) - .and_then(|n| n.as_element().map(|e| matches!(e.local.as_ref(), "td" | "th"))) + .and_then(|n| { + n.as_element() + .map(|e| matches!(e.local.as_ref(), "td" | "th")) + }) .unwrap_or(false); if !is_cell { continue; @@ -7358,9 +7463,9 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap) { let mut section: Option = None; for row in tree.children(id) { let is_row = tree.get_node(row).map_or(false, |node| { - node.as_element() - .map_or(false, |element| element.local.as_ref() == "tr") - }); + node.as_element() + .map_or(false, |element| element.local.as_ref() == "tr") + }); if !is_row { continue; } @@ -7432,8 +7537,10 @@ fn reliable_normal_flow_content_width( } let containing_width = if let Some(parent_id) = parent { if let Some(parent_style) = styles.get(&parent_id) { - if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid) - || parent_style.internal_flex_container + if matches!( + parent_style.display, + crate::Display::Flex | crate::Display::Grid + ) || parent_style.internal_flex_container || parent_style.column_count.is_some() { return None; @@ -7471,11 +7578,7 @@ fn reliable_normal_flow_content_width( _ => None, }; let mut content_width = declared_content(style.width).unwrap_or_else(|| { - (containing_width - - style.margin.left - - style.margin.right - - horizontal_edges) - .max(0.0) + (containing_width - style.margin.left - style.margin.right - horizontal_edges).max(0.0) }); if let Some(minimum) = declared_content(style.min_width) { content_width = content_width.max(minimum); @@ -7510,26 +7613,19 @@ fn reliable_table_available_width( let containing_width = match parent { Some(parent_id) if styles.contains_key(&parent_id) => { let parent_style = styles.get(&parent_id)?; - if matches!(parent_style.display, crate::Display::Flex | crate::Display::Grid) - || parent_style.internal_flex_container + if matches!( + parent_style.display, + crate::Display::Flex | crate::Display::Grid + ) || parent_style.internal_flex_container || parent_style.column_count.is_some() { return None; } - reliable_normal_flow_content_width( - tree, - parent_id, - styles, - initial_cb_width, - 0, - )? + reliable_normal_flow_content_width(tree, parent_id, styles, initial_cb_width, 0)? } _ => initial_cb_width, }; - Some( - (containing_width - table_style.margin.left - table_style.margin.right) - .max(0.0), - ) + Some((containing_width - table_style.margin.left - table_style.margin.right).max(0.0)) } /// Resolve the `width: fit-content` keyword after the containing inline space @@ -7555,11 +7651,7 @@ fn apply_fit_content_widths( mut intrinsic_width: F, ) -> bool where - F: FnMut( - &mut TaffyTree, - taffy::NodeId, - taffy::AvailableSpace, - ) -> Option, + F: FnMut(&mut TaffyTree, taffy::NodeId, taffy::AvailableSpace) -> Option, { struct Candidate { node: taffy::NodeId, @@ -7580,8 +7672,10 @@ where } let layout = taffy_tree.layout(node).ok()?; let margin = layout.margin.left + layout.margin.right; - let inline_edges = - layout.padding.left + layout.padding.right + layout.border.left + layout.border.right; + let inline_edges = layout.padding.left + + layout.padding.right + + layout.border.left + + layout.border.right; let parent = taffy_tree.parent(node); let parent_content = parent @@ -7625,8 +7719,7 @@ where taffy::Display::Flex if matches!( parent_style.flex_direction, - taffy::FlexDirection::Column - | taffy::FlexDirection::ColumnReverse + taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse ) => { let child_align = taffy_tree @@ -7698,7 +7791,9 @@ where fn collect_table_rows(tree: &DomTree, id: NodeId, rows: &mut Vec<(NodeId, usize)>) { let mut direct_start: Option = None; for cid in tree.children(id) { - let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())); + let local = tree + .get_node(cid) + .and_then(|n| n.as_element().map(|e| e.local.to_string())); match local.as_deref() { Some("tr") => { direct_start.get_or_insert(rows.len()); @@ -7803,7 +7898,10 @@ fn build_table( // that spans down pushes later rows' cells past the columns it still covers. let span_attr = |cid: NodeId, name: &str| -> usize { tree.get_node(cid) - .and_then(|n| n.get_attribute(name).and_then(|v| v.trim().parse::().ok())) + .and_then(|n| { + n.get_attribute(name) + .and_then(|v| v.trim().parse::().ok()) + }) .unwrap_or(1) }; let mut occupied: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); @@ -7812,13 +7910,19 @@ fn build_table( for (r, &(tr, group_end)) in rows.iter().enumerate() { let mut c = 0usize; for cid in tree.children(tr) { - let local = tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())); + let local = tree + .get_node(cid) + .and_then(|n| n.as_element().map(|e| e.local.to_string())); if !matches!(local.as_deref(), Some("td") | Some("th")) { continue; } // A hidden cell is removed from the table model entirely (it must // not reserve a column slot, or the surviving cells shift right). - if styles.get(&cid).map(|s| s.display == crate::Display::None).unwrap_or(false) { + if styles + .get(&cid) + .map(|s| s.display == crate::Display::None) + .unwrap_or(false) + { continue; } while occupied.contains(&(r, c)) { @@ -7832,8 +7936,12 @@ fn build_table( // spans are clipped at the same boundary by the effective cell map. let rs_raw = span_attr(cid, "rowspan"); let rows_left_in_group = group_end.min(nrows).saturating_sub(r).max(1); - let rs = if rs_raw == 0 { rows_left_in_group } else { rs_raw } - .clamp(1, rows_left_in_group); + let rs = if rs_raw == 0 { + rows_left_in_group + } else { + rs_raw + } + .clamp(1, rows_left_in_group); for dr in 0..rs { for dc in 0..cs { occupied.insert((r + dr, c + dc)); @@ -7851,13 +7959,21 @@ fn build_table( // Build each cell and pin it to its grid area. let mut children: Vec = Vec::new(); for (cid, r, c, rs, cs) in &placed { - let Some(cell_node) = build(tree, *cid, taffy_tree, id_map, words, engine, ifc_items, styles) else { + let Some(cell_node) = build( + tree, *cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) else { continue; }; if let Ok(cur) = taffy_tree.style(cell_node) { let mut cstyle = cur.clone(); - cstyle.grid_row = taffy::Line { start: line((*r as i16) + 1), end: span(*rs as u16) }; - cstyle.grid_column = taffy::Line { start: line((*c as i16) + 1), end: span(*cs as u16) }; + cstyle.grid_row = taffy::Line { + start: line((*r as i16) + 1), + end: span(*rs as u16), + }; + cstyle.grid_column = taffy::Line { + start: line((*c as i16) + 1), + end: span(*cs as u16), + }; // Grid does the sizing; a leftover flex_grow from the flex-table // heuristic would be ignored anyway, but clear it to be explicit. cstyle.flex_grow = 0.0; @@ -7885,10 +8001,16 @@ fn build_table( let mut col_px: Vec> = vec![None; ncols]; let mut col_pct: Vec> = vec![None; ncols]; let attr_width = |cid: NodeId| -> (Option, Option) { - let Some(v) = tree.get_node(cid).and_then(|n| n.get_attribute("width").map(|s| s.trim().to_string())) else { + let Some(v) = tree + .get_node(cid) + .and_then(|n| n.get_attribute("width").map(|s| s.trim().to_string())) + else { return (None, None); }; - if let Some(p) = v.strip_suffix('%').and_then(|s| s.trim().parse::().ok()) { + if let Some(p) = v + .strip_suffix('%') + .and_then(|s| s.trim().parse::().ok()) + { (None, Some(p / 100.0)) } else { (v.trim_end_matches("px").trim().parse::().ok(), None) @@ -7905,11 +8027,19 @@ fn build_table( let mut next_col = 0usize; let mut col_elems: Vec = Vec::new(); for cid in tree.children(id) { - match tree.get_node(cid).and_then(|n| n.as_element().map(|e| e.local.to_string())).as_deref() { + match tree + .get_node(cid) + .and_then(|n| n.as_element().map(|e| e.local.to_string())) + .as_deref() + { Some("col") => col_elems.push(cid), Some("colgroup") => { for gc in tree.children(cid) { - if tree.get_node(gc).and_then(|n| n.as_element().map(|e| e.local.as_ref() == "col")).unwrap_or(false) { + if tree + .get_node(gc) + .and_then(|n| n.as_element().map(|e| e.local.as_ref() == "col")) + .unwrap_or(false) + { col_elems.push(gc); } } @@ -7920,7 +8050,10 @@ fn build_table( for col_el in col_elems { let span = tree .get_node(col_el) - .and_then(|n| n.get_attribute("span").and_then(|v| v.trim().parse::().ok())) + .and_then(|n| { + n.get_attribute("span") + .and_then(|v| v.trim().parse::().ok()) + }) .unwrap_or(1) .clamp(1, MAX_SPAN); let (px, pct) = style_width(col_el); @@ -7946,12 +8079,7 @@ fn build_table( // receive a particular cell's box edges. if let (Some(w), Some(s)) = (px, styles.get(cid)) { if s.box_sizing == crate::BoxSizing::ContentBox { - px = Some( - w + s.padding.left - + s.padding.right - + s.border.left - + s.border.right, - ); + px = Some(w + s.padding.left + s.padding.right + s.border.left + s.border.right); } } if let Some(w) = px { @@ -7991,14 +8119,20 @@ fn build_table( } else { taffy::MaxTrackSizingFunction::auto() }; - taffy::GridTemplateComponent::Single(taffy::MinMax { min: taffy::MinTrackSizingFunction::min_content(), max }) + taffy::GridTemplateComponent::Single(taffy::MinMax { + min: taffy::MinTrackSizingFunction::min_content(), + max, + }) }; let row_track = |r: usize| { let min = match row_min[r] { Some(h) => taffy::MinTrackSizingFunction::length(h), None => taffy::MinTrackSizingFunction::auto(), }; - taffy::GridTemplateComponent::Single(taffy::MinMax { min, max: taffy::MaxTrackSizingFunction::auto() }) + taffy::GridTemplateComponent::Single(taffy::MinMax { + min, + max: taffy::MaxTrackSizingFunction::auto(), + }) }; // In the separate-border model, border-spacing also exists between the // table edge and the first/last row and column. Grid `gap` only covers @@ -8050,7 +8184,10 @@ fn effective_container_type(style: &crate::LayoutStyle) -> crate::ContainerType } #[inline] -fn used_flex_alignment(value: Option, parent: Option) -> taffy::AlignSelf { +fn used_flex_alignment( + value: Option, + parent: Option, +) -> taffy::AlignSelf { value .or(parent) .unwrap_or(taffy::AlignSelf::NORMAL) @@ -8062,8 +8199,14 @@ fn used_grid_alignments( style: &crate::LayoutStyle, parent: &crate::LayoutStyle, ) -> (taffy::AlignSelf, taffy::AlignSelf) { - let horizontal = style.justify_self.or(parent.justify_items).unwrap_or(taffy::AlignSelf::NORMAL); - let vertical = style.align_self.or(parent.align_items).unwrap_or(taffy::AlignSelf::NORMAL); + let horizontal = style + .justify_self + .or(parent.justify_items) + .unwrap_or(taffy::AlignSelf::NORMAL); + let vertical = style + .align_self + .or(parent.align_items) + .unwrap_or(taffy::AlignSelf::NORMAL); if style.has_replaced_sizing { return ( horizontal.resolve_normal(taffy::AlignSelf::START), @@ -8157,10 +8300,7 @@ fn container_auto_inline_size( crate::Display::Flex => { let row = !matches!( parent_style.flex_direction, - Some( - taffy::FlexDirection::Column - | taffy::FlexDirection::ColumnReverse - ) + Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse) ); if row { ContainerAutoInlineSize::Intrinsic @@ -8345,10 +8485,7 @@ fn defer_cyclic_flex_inline_sizes( && !parent_style.internal_flex_container && !matches!( parent_style.flex_direction, - Some( - taffy::FlexDirection::Column - | taffy::FlexDirection::ColumnReverse - ) + Some(taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse) ); let item_is_indefinite = styles.get(&item).map_or(false, |style| { (!matches!(style.width, crate::Dimension::Px(_)) @@ -8356,10 +8493,7 @@ fn defer_cyclic_flex_inline_sizes( .as_deref() .is_some_and(|expression| expression.contains('%'))) && style.float.is_none() - && !matches!( - style.position, - Some(taffy::Position::Absolute) - ) + && !matches!(style.position, Some(taffy::Position::Absolute)) }); if row_flex && item_is_indefinite { flex_item = Some(item); @@ -8396,7 +8530,11 @@ fn defer_cyclic_flex_inline_sizes( _ => unreachable!(), } } - deferred.push(DeferredCyclicInlineSize { node: id, flex_item, slot }); + deferred.push(DeferredCyclicInlineSize { + node: id, + flex_item, + slot, + }); } deferred @@ -8524,8 +8662,12 @@ fn is_in_flow_grid_item( } let mut parent = tree.get_node(id).and_then(|node| node.parent); loop { - let Some(parent_id) = parent else { return false }; - let Some(parent_style) = styles.get(&parent_id) else { return false }; + let Some(parent_id) = parent else { + return false; + }; + let Some(parent_style) = styles.get(&parent_id) else { + return false; + }; if parent_style.display_contents { parent = tree.get_node(parent_id).and_then(|node| node.parent); continue; @@ -8556,7 +8698,9 @@ fn build( // grid spans. Falls through to the generic path only if the table has no // usable rows/cells. if _name.local.as_ref() == "table" { - if let Some(node) = build_table(tree, id, taffy_tree, id_map, words, engine, ifc_items, styles) { + if let Some(node) = build_table( + tree, id, taffy_tree, id_map, words, engine, ifc_items, styles, + ) { return Some(node); } } @@ -8624,7 +8768,9 @@ fn build( let mut parent = node.parent; let flex_or_grid_item = loop { let Some(parent_id) = parent else { break false }; - let Some(parent_style) = styles.get(&parent_id) else { break false }; + let Some(parent_style) = styles.get(&parent_id) else { + break false; + }; if parent_style.display_contents { parent = tree.get_node(parent_id).and_then(|node| node.parent); continue; @@ -8645,11 +8791,9 @@ fn build( // wrapping for explicitly constrained inline-blocks and for ones // with real in-flow block children; those need their inner block // formatting rather than a single max-content line. - let has_in_flow_block_child = rendered_children(tree, id).iter().any(|child| { - styles - .get(child) - .map_or(false, is_in_flow_block_level) - }); + let has_in_flow_block_child = rendered_children(tree, id) + .iter() + .any(|child| styles.get(child).map_or(false, is_in_flow_block_level)); if !has_in_flow_block_child { taffy_style.flex_wrap = taffy::FlexWrap::NoWrap; } @@ -8664,7 +8808,9 @@ fn build( // empty container makes its intrinsic contribution zero during grid track // sizing, collapsing responsive logo walls to zero-height rows. if _name.local.as_ref() == "svg" { - if let Some(ratio) = style.aspect_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.0) + if let Some(ratio) = style + .aspect_ratio + .filter(|ratio| ratio.is_finite() && *ratio > 0.0) { // A percentage inline size inside an auto grid track is cyclic // during intrinsic sizing: it behaves as auto for the contribution @@ -8694,17 +8840,19 @@ fn build( } } - if let Some((width, height)) = - crate::inline::default_replaced_intrinsic_size( - _name.local.as_ref(), - style.font_size.unwrap_or(16.0), - node.get_attribute("controls").is_some(), - _name.local.as_ref() != "embed" - || node.get_attribute("src").is_some_and(|value| !value.trim().is_empty()), - ) - { + if let Some((width, height)) = crate::inline::default_replaced_intrinsic_size( + _name.local.as_ref(), + style.font_size.unwrap_or(16.0), + node.get_attribute("controls").is_some(), + _name.local.as_ref() != "embed" + || node + .get_attribute("src") + .is_some_and(|value| !value.trim().is_empty()), + ) { let context = engine.register_replaced(width, height, style); - let leaf = taffy_tree.new_leaf_with_context(taffy_style, context).ok()?; + let leaf = taffy_tree + .new_leaf_with_context(taffy_style, context) + .ok()?; id_map.insert(leaf, id); return Some(leaf); } @@ -8742,15 +8890,14 @@ fn build( crate::Dimension::Px(height) => Some(height * preferred_ratio), _ => Some( crate::inline::constrained_auto_replaced_size(width, height, style) - .width, + .width, ), }, _ => None, }; if let Some(preferred_width) = preferred_width { taffy_style.size.width = taffy::Dimension::percent(maximum); - taffy_style.max_size.width = - taffy::Dimension::length(preferred_width.max(0.0)); + taffy_style.max_size.width = taffy::Dimension::length(preferred_width.max(0.0)); } } @@ -8808,22 +8955,24 @@ fn build( // their percentage width becomes definite, and taffy then needs // the ratio to transfer that final width to height. let measured_axis_constraint = (!matches!(style.height, crate::Dimension::Auto) - && matches!(style.width, crate::Dimension::Auto) + && matches!(style.width, crate::Dimension::Auto) + && matches!( + (style.min_width, style.max_width), + (crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_)) + )) + || (!matches!(style.width, crate::Dimension::Auto) + && matches!(style.height, crate::Dimension::Auto) && matches!( - (style.min_width, style.max_width), + (style.min_height, style.max_height), (crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_)) - )) - || (!matches!(style.width, crate::Dimension::Auto) - && matches!(style.height, crate::Dimension::Auto) - && matches!( - (style.min_height, style.max_height), - (crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_)) - )); + )); if measured_axis_constraint { taffy_style.aspect_ratio = None; } let context = engine.register_replaced(width, height, style); - let leaf = taffy_tree.new_leaf_with_context(taffy_style, context).ok()?; + let leaf = taffy_tree + .new_leaf_with_context(taffy_style, context) + .ok()?; id_map.insert(leaf, id); return Some(leaf); } @@ -8893,9 +9042,9 @@ fn build( dom_children = flattened; } let internal_mixed_block_flow = style.internal_flex_container - && dom_children.iter().any(|cid| { - styles.get(cid).map_or(false, is_in_flow_block_level) - }); + && dom_children + .iter() + .any(|cid| styles.get(cid).map_or(false, is_in_flow_block_level)); // In flex and grid formatting contexts, collapsible whitespace-only text // between items does not generate an anonymous item. Taffy places each // stray whitespace leaf in the item sequence, shifting every real child. @@ -8912,7 +9061,8 @@ fn build( flatten_contents_children(tree, &dom_children, styles, &mut flat); dom_children = flat; dom_children.retain(|&cid| { - tree.get_node(cid).map_or(false, |n| n.is_element()) || !tree.text_content(cid).trim().is_empty() + tree.get_node(cid).map_or(false, |n| n.is_element()) + || !tree.text_content(cid).trim().is_empty() }); // Flex and grid placement consume the order-modified document order. // Rust's stable sort preserves source order for equal values, exactly @@ -8974,15 +9124,16 @@ fn build( // flex container; routing those children through the block float-zone // approximation corrupts flex sizing and percentage-margin placement. let has_float_child = style.display == crate::Display::Block - && dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); + && dom_children + .iter() + .any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); let native_float_band = has_float_child && can_use_native_float_band(tree, style, &dom_children, styles); - let has_in_flow_block_child = dom_children.iter().any(|cid| { - styles.get(cid).map_or(false, is_in_flow_block_level) - }); + let has_in_flow_block_child = dom_children + .iter() + .any(|cid| styles.get(cid).map_or(false, is_in_flow_block_level)); let is_native_table_cell = node.as_element().map_or(false, |element| { - matches!(element.local.as_ref(), "td" | "th") - && style.internal_flex_container + matches!(element.local.as_ref(), "td" | "th") && style.internal_flex_container }); // `text-align` affects inline content, never the used width or placement @@ -8995,10 +9146,7 @@ fn build( // // Keep the legacy `
` behavior: unlike CSS text-align, that element // historically centers block descendants as well. - if style.display == crate::Display::Block - && has_in_flow_block_child - && !style.legacy_center - { + if style.display == crate::Display::Block && has_in_flow_block_child && !style.legacy_center { taffy_style.display = taffy::style::Display::Block; } @@ -9012,12 +9160,23 @@ fn build( // block children (e.g. a `position:relative` hero wrapper whose only // child is out-of-flow) to width 0 and let text and blocks share lines. // Floats still take the legacy zone path below. - if (style.display == crate::Display::Block - || (is_native_table_cell && has_in_flow_block_child)) + if (style.display == crate::Display::Block || (is_native_table_cell && has_in_flow_block_child)) && has_inline_ish_content && !has_float_child { - return build_mixed_block(tree, id, style, taffy_style, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles); + return build_mixed_block( + tree, + id, + style, + taffy_style, + &dom_children, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ); } if stacks_children_vertically @@ -9062,7 +9221,17 @@ fn build( styles, ) } else if has_float_child { - build_children_with_float_zone(tree, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) + build_children_with_float_zone( + tree, + id, + &dom_children, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ) } else if matches!(style.display, crate::Display::Flex | crate::Display::Grid) && !style.internal_flex_container { @@ -9078,7 +9247,14 @@ fn build( styles, ) } else { - dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect() + dom_children + .into_iter() + .flat_map(|cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) + .collect() }; if !native_float_band { if let Some((mut before, _)) = build_in_flow_pseudo( @@ -9122,10 +9298,7 @@ fn build( child_style.display == crate::Display::Block && child_style.width == crate::Dimension::Auto && child_style.float.is_none() - && !matches!( - child_style.position, - Some(taffy::Position::Absolute) - ) + && !matches!(child_style.position, Some(taffy::Position::Absolute)) }); if let Ok(current) = taffy_tree.style(*child) { let mut block_item = current.clone(); @@ -9156,15 +9329,10 @@ fn build( .map_or(false, |child_style| { child_style.break_inside_avoid && child_style.float.is_none() - && !matches!( - child_style.position, - Some(taffy::Position::Absolute) - ) + && !matches!(child_style.position, Some(taffy::Position::Absolute)) }) }); - let taffy_id = if let Some(column_count) = - multicol_count.filter(|_| atomic_multicol_children) - { + let taffy_id = if let Some(column_count) = multicol_count.filter(|_| atomic_multicol_children) { // A multicol container keeps its ordinary outer/block box, but its // inner formatting context is a horizontal sequence of equal-width // fragmentainers. Taffy does not expose CSS fragmentation, so build @@ -9200,8 +9368,7 @@ fn build( for _ in 1..column_count { columns.push(taffy_tree.new_leaf(column_style.clone()).ok()?); } - let multicol = - taffy_tree.new_with_children(taffy_style, &columns).ok()?; + let multicol = taffy_tree.new_with_children(taffy_style, &columns).ok()?; ifc_items.multicol.push(MulticolBuild { columns, children: child_ids, @@ -9239,7 +9406,9 @@ fn inline_wrapper_float( .children(wrapper) .into_iter() .filter(|&child| { - let Some(node) = tree.get_node(child) else { return false }; + let Some(node) = tree.get_node(child) else { + return false; + }; if !node.is_element() { return !tree.text_content(child).trim().is_empty(); } @@ -9249,8 +9418,13 @@ fn inline_wrapper_float( .unwrap_or(false) }) .collect(); - let [child] = visible.as_slice() else { return None }; - styles.get(child).and_then(|style| style.float).map(|_| *child) + let [child] = visible.as_slice() else { + return None; + }; + styles + .get(child) + .and_then(|style| style.float) + .map(|_| *child) } /// Build a block container whose children mix inline-level and block-level @@ -9302,7 +9476,9 @@ fn build_mixed_block( let mut segs: Vec = Vec::new(); let mut out_of_flow: Vec = Vec::new(); for &cid in &flat { - let Some(node) = tree.get_node(cid) else { continue }; + let Some(node) = tree.get_node(cid) else { + continue; + }; let is_text = matches!(node.data, obscura_dom::tree::NodeData::Text { .. }); // Comments, doctypes, and other non-rendered DOM nodes generate no CSS // box and cannot interrupt an inline formatting context. Hydrating @@ -9371,8 +9547,9 @@ fn build_mixed_block( for (i, seg) in segs.into_iter().enumerate() { match seg { Seg::Block(cid) => { - let built = - build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles); + let built = build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ); if style.legacy_center { let has_default_horizontal_margins = styles.get(&cid).map_or(false, |child| { child.margin.left == 0.0 @@ -9384,10 +9561,8 @@ fn build_mixed_block( for child in &built { if let Ok(current) = taffy_tree.style(*child) { let mut centered = current.clone(); - centered.margin.left = - taffy::style::LengthPercentageAuto::auto(); - centered.margin.right = - taffy::style::LengthPercentageAuto::auto(); + centered.margin.left = taffy::style::LengthPercentageAuto::auto(); + centered.margin.right = taffy::style::LengthPercentageAuto::auto(); let _ = taffy_tree.set_style(*child, centered); } } @@ -9398,10 +9573,7 @@ fn build_mixed_block( Seg::Run(run) => { let has_text_strut = run.iter().any(|&cid| { tree.get_node(cid).map_or(false, |node| { - matches!( - node.data, - obscura_dom::tree::NodeData::Text { .. } - ) + matches!(node.data, obscura_dom::tree::NodeData::Text { .. }) }) }); // Collapsible source formatting at the start/end of an inline @@ -9414,7 +9586,10 @@ fn build_mixed_block( && tree.text_content(cid).trim().is_empty() }) }; - let start = run.iter().position(|&cid| !is_whitespace_text(cid)).unwrap_or(run.len()); + let start = run + .iter() + .position(|&cid| !is_whitespace_text(cid)) + .unwrap_or(run.len()); let end = run .iter() .rposition(|&cid| !is_whitespace_text(cid)) @@ -9427,7 +9602,9 @@ fn build_mixed_block( // pseudo-content word leaves must share its lines. if !join_before && !join_after { if let Some(item) = engine.try_build_run(tree, id, run, styles) { - let leaf = taffy_tree.new_leaf_with_context(run_leaf_style(), item).ok()?; + let leaf = taffy_tree + .new_leaf_with_context(run_leaf_style(), item) + .ok()?; ifc_items.runs.entry(id).or_default().push(item); child_ids.push(leaf); continue; @@ -9439,12 +9616,10 @@ fn build_mixed_block( before_pending = false; } for &rc in run { - let is_forced_break = tree - .get_node(rc) - .is_some_and(|node| { - node.as_element() - .is_some_and(|element| element.local.as_ref() == "br") - }); + let is_forced_break = tree.get_node(rc).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "br") + }); if is_forced_break { // A BR participates in the current line with zero // inline size, then forces the following content onto @@ -9454,14 +9629,7 @@ fn build_mixed_block( // the current line strut, while an anonymous 100% // breaker may occupy its own zero-height flex line. atoms.extend(build_any( - tree, - rc, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles, )); let breaker_style = taffy::Style { flex_grow: 0.0, @@ -9476,14 +9644,7 @@ fn build_mixed_block( atoms.push(taffy_tree.new_leaf(breaker_style).ok()?); } else { atoms.extend(build_any( - tree, - rc, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles, )); } } @@ -9496,10 +9657,7 @@ fn build_mixed_block( continue; } let wrapper = taffy_tree - .new_with_children( - run_wrapper_style(style, has_text_strut), - &atoms, - ) + .new_with_children(run_wrapper_style(style, has_text_strut), &atoms) .ok()?; child_ids.push(wrapper); } @@ -9522,7 +9680,9 @@ fn build_mixed_block( child_ids.extend(after_leaves.iter().copied()); } for cid in out_of_flow { - child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)); + child_ids.extend(build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + )); } let taffy_id = if child_ids.is_empty() { @@ -9626,7 +9786,10 @@ fn flatten_contents_children( out: &mut Vec, ) { for &cid in children { - let splices = styles.get(&cid).map(|s| s.display_contents && s.display != crate::Display::None).unwrap_or(false); + let splices = styles + .get(&cid) + .map(|s| s.display_contents && s.display != crate::Display::None) + .unwrap_or(false); if splices { let kids = rendered_children(tree, cid); flatten_contents_children(tree, &kids, styles, out); @@ -9736,7 +9899,10 @@ fn flatten_boxless_inline_children( /// containing block's width, with height from the shaped text (measure fn). fn run_leaf_style() -> taffy::Style { taffy::Style { - size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() }, + size: taffy::Size { + width: taffy::style::Dimension::percent(1.0), + height: taffy::style::Dimension::auto(), + }, ..Default::default() } } @@ -9746,10 +9912,7 @@ fn run_leaf_style() -> taffy::Style { /// context. The parent's `text-align` moves the run's line content via /// justify-content, exactly as the old whole-container /// promotion did, but scoped to the run so sibling blocks stay full width. -fn run_wrapper_style( - parent: &crate::LayoutStyle, - has_text_strut: bool, -) -> taffy::Style { +fn run_wrapper_style(parent: &crate::LayoutStyle, has_text_strut: bool) -> taffy::Style { let justify = match parent.text_align { Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END), Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER), @@ -9766,7 +9929,10 @@ fn run_wrapper_style( flex_wrap: taffy::FlexWrap::Wrap, align_items: Some(taffy::AlignItems::FLEX_START), justify_content: justify, - size: taffy::Size { width: taffy::style::Dimension::percent(1.0), height: taffy::style::Dimension::auto() }, + size: taffy::Size { + width: taffy::style::Dimension::percent(1.0), + height: taffy::style::Dimension::auto(), + }, // Every CSS line box starts with the parent's font/line-height strut, // even when its only atomic inline is shorter (or zero-sized). min_size: taffy::Size { @@ -9804,7 +9970,11 @@ const DEFAULT_FLOAT_HEIGHT_ESTIMATE: f32 = 200.0; /// text-based estimate of the float's own content (the common tall-infobox /// shape, where the height comes from many rows of text rather than a /// single image). -fn estimate_float_height(tree: &DomTree, float_id: NodeId, styles: &HashMap) -> f32 { +fn estimate_float_height( + tree: &DomTree, + float_id: NodeId, + styles: &HashMap, +) -> f32 { if let Some(crate::Dimension::Px(h)) = styles.get(&float_id).map(|s| s.height) { return h; } @@ -9812,7 +9982,10 @@ fn estimate_float_height(tree: &DomTree, float_id: NodeId, styles: &HashMap Some(h), @@ -9835,13 +10008,28 @@ fn estimate_float_height(tree: &DomTree, float_id: NodeId, styles: &HashMap, assumed_width: f32) -> f32 { - let char_count = tree.text_content(id).chars().filter(|c| !c.is_whitespace()).count() as f32; +fn estimate_text_height( + tree: &DomTree, + id: NodeId, + styles: &HashMap, + assumed_width: f32, +) -> f32 { + let char_count = tree + .text_content(id) + .chars() + .filter(|c| !c.is_whitespace()) + .count() as f32; if char_count == 0.0 { return 0.0; } @@ -9874,7 +10071,9 @@ fn estimate_flow_sibling_height( assumed_width: f32, ) -> f32 { let style = styles.get(&id); - if style.map(|style| style.display == crate::Display::None).unwrap_or(false) + if style + .map(|style| style.display == crate::Display::None) + .unwrap_or(false) || style .and_then(|style| style.position) .map(|position| position == taffy::Position::Absolute) @@ -9891,19 +10090,25 @@ fn estimate_flow_sibling_height( .into_iter() .filter(|&descendant| { tree.get_node(descendant) - .and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img")) + .and_then(|node| { + node.as_element() + .map(|element| element.local.as_ref() == "img") + }) .unwrap_or(false) }) .filter_map( |descendant| match styles.get(&descendant).map(|style| style.height) { - Some(crate::Dimension::Px(height)) => Some(height.max(0.0)), - _ => None, + Some(crate::Dimension::Px(height)) => Some(height.max(0.0)), + _ => None, }, ) .sum(); let own_image_height = if tree .get_node(id) - .and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img")) + .and_then(|node| { + node.as_element() + .map(|element| element.local.as_ref() == "img") + }) .unwrap_or(false) { explicit_height @@ -9913,7 +10118,9 @@ fn estimate_flow_sibling_height( let content_height = estimate_text_height(tree, id, styles, assumed_width) .max(explicit_height) .max(descendant_image_height + own_image_height); - let margins = style.map(|style| (style.margin.top + style.margin.bottom).max(0.0)).unwrap_or(0.0); + let margins = style + .map(|style| (style.margin.top + style.margin.bottom).max(0.0)) + .unwrap_or(0.0); content_height + margins } @@ -9924,7 +10131,11 @@ fn estimate_flow_sibling_height( /// and starves the adjacent flow column to nothing. Real browsers size the /// figure to the image (display:table) and wrap the caption; the image's /// definite width is the bound that reproduces that. -fn max_definite_descendant_width(tree: &DomTree, id: NodeId, styles: &HashMap) -> Option { +fn max_definite_descendant_width( + tree: &DomTree, + id: NodeId, + styles: &HashMap, +) -> Option { let mut best: Option = None; for d in tree.descendants(id) { if let Some(crate::Dimension::Px(w)) = styles.get(&d).map(|s| s.width.clone()) { @@ -9967,8 +10178,7 @@ fn max_definite_table_content_width( if structural { continue; } - if let Some(crate::Dimension::Px(width)) = - styles.get(&descendant).map(|style| style.width) + if let Some(crate::Dimension::Px(width)) = styles.get(&descendant).map(|style| style.width) { if width > 0.0 { best = Some(best.map_or(width, |current| current.max(width))); @@ -10000,11 +10210,7 @@ fn has_deferred_or_auto_margin(style: &crate::LayoutStyle) -> bool { || style.margin_expressions.iter().any(Option::is_some) } -fn is_structural_native_clear_box( - tree: &DomTree, - id: NodeId, - style: &crate::LayoutStyle, -) -> bool { +fn is_structural_native_clear_box(tree: &DomTree, id: NodeId, style: &crate::LayoutStyle) -> bool { style.display == crate::Display::Block && !style.display_contents && !style.is_table_box @@ -10074,7 +10280,9 @@ fn can_use_native_float_band( } for &id in dom_children { - let Some(node) = tree.get_node(id) else { continue }; + let Some(node) = tree.get_node(id) else { + continue; + }; if !node.is_element() { if !tree.text_content(id).trim().is_empty() { return false; @@ -10093,7 +10301,10 @@ fn can_use_native_float_band( || style.display_contents || style.is_table_box || matches!(style.position, Some(taffy::Position::Absolute)) - || !matches!(style.width, crate::Dimension::Px(_) | crate::Dimension::Percent(_)) + || !matches!( + style.width, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ) || style.size_expressions[0].is_some() || has_deferred_or_auto_margin(style) { @@ -10129,8 +10340,8 @@ fn can_use_native_float_band( // `clip` does not establish a BFC, and viewport-propagated overflow leaves // its source box visible. Other Obscura BFC markers do not yet have a // distinct taffy-side representation, so they are not an escape signal. - let parent_is_native_bfc = parent_style.overflow_scroll_container - && !parent_style.overflow_propagated_to_viewport; + let parent_is_native_bfc = + parent_style.overflow_scroll_container && !parent_style.overflow_propagated_to_viewport; saw_float && (saw_clear_after_floats || parent_is_native_bfc) } @@ -10140,7 +10351,9 @@ fn set_native_float_clear( style: &crate::LayoutStyle, generated_pseudo: bool, ) { - let Ok(current) = taffy_tree.style(node) else { return }; + let Ok(current) = taffy_tree.style(node) else { + return; + }; let mut native = current.clone(); native.float = match style.float { Some(crate::Float::Left) => taffy::style::Float::Left, @@ -10178,12 +10391,20 @@ fn build_children_with_native_float_band( ) -> Vec { let mut result = Vec::new(); for (kind, pseudo) in [ - (GeneratedBoxKind::Before, parent_style.before_pseudo.as_deref()), - (GeneratedBoxKind::After, parent_style.after_pseudo.as_deref()), + ( + GeneratedBoxKind::Before, + parent_style.before_pseudo.as_deref(), + ), + ( + GeneratedBoxKind::After, + parent_style.after_pseudo.as_deref(), + ), ] { if kind == GeneratedBoxKind::After { for &id in dom_children { - let Some(style) = styles.get(&id) else { continue }; + let Some(style) = styles.get(&id) else { + continue; + }; for node in build_any( tree, id, taffy_tree, id_map, words, engine, ifc_items, styles, ) { @@ -10251,9 +10472,9 @@ fn build_children_with_float_zone( let parent_min_height = styles .get(&parent_id) .and_then(|parent| match parent.min_height { - crate::Dimension::Px(value) => Some(value), - _ => None, - }); + crate::Dimension::Px(value) => Some(value), + _ => None, + }); if parent_has_definite_height || parent_has_clearfix || parent_min_height.is_some() { let fills_axis = |value: f32| (value - 1.0).abs() < 0.001; let substantive: Vec = dom_children @@ -10329,14 +10550,7 @@ fn build_children_with_float_zone( if sole_full_width_float || split_band_flow.is_some() { let float_node = build( - tree, - float_dom, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, float_dom, taffy_tree, id_map, words, engine, ifc_items, styles, ); // The split-band guard admits only a boxed inline-block // element, so it must be built as one atomic box. Calling @@ -10346,20 +10560,15 @@ fn build_children_with_float_zone( // detached nodes behind when falling back to the general path. let flow_node = split_band_flow.and_then(|flow_dom| { build( - tree, - flow_dom, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, flow_dom, taffy_tree, id_map, words, engine, ifc_items, styles, ) }); if let Some(float_node) = float_node { if sole_full_width_float || flow_node.is_some() { - let children: Vec = - [Some(float_node), flow_node].into_iter().flatten().collect(); + let children: Vec = [Some(float_node), flow_node] + .into_iter() + .flatten() + .collect(); let band_style = taffy::Style { display: taffy::style::Display::Flex, flex_direction: taffy::FlexDirection::Row, @@ -10401,15 +10610,13 @@ fn build_children_with_float_zone( .copied() .filter(|cid| { styles.get(cid).map_or(false, |style| { - style.float == Some(crate::Float::Right) - && style.display != crate::Display::None + style.float == Some(crate::Float::Right) && style.display != crate::Display::None }) }) .collect(); let has_left_float = dom_children.iter().any(|cid| { styles.get(cid).map_or(false, |style| { - style.float == Some(crate::Float::Left) - && style.display != crate::Display::None + style.float == Some(crate::Float::Left) && style.display != crate::Display::None }) }); let flow_is_inline = dom_children.iter().all(|cid| { @@ -10456,14 +10663,7 @@ fn build_children_with_float_zone( .into_iter() .flat_map(|cid| { build_any( - tree, - cid, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, ) }) .collect(); @@ -10472,14 +10672,7 @@ fn build_children_with_float_zone( .rev() .filter_map(|cid| { build( - tree, - *cid, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, *cid, taffy_tree, id_map, words, engine, ifc_items, styles, ) }) .collect(); @@ -10511,9 +10704,7 @@ fn build_children_with_float_zone( }, ..Default::default() }; - if let Ok(row) = - taffy_tree.new_with_children(row_style, &row_children) - { + if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) { return vec![row]; } } @@ -10521,12 +10712,23 @@ fn build_children_with_float_zone( } let Some(float_idx) = dom_children.iter().position(|&cid| is_float(cid)) else { - return dom_children.iter().flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect(); + return dom_children + .iter() + .flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) + .collect(); }; let mut result: Vec = dom_children[..float_idx] .iter() - .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) + .flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) .collect(); let float_side = styles.get(&dom_children[float_idx]).and_then(|s| s.float); @@ -10538,7 +10740,9 @@ fn build_children_with_float_zone( // scans the same BFC band and puts the second float against the opposite // edge when both margin boxes fit. let is_empty_bridge = |cid: NodeId| { - let Some(node) = tree.get_node(cid) else { return true }; + let Some(node) = tree.get_node(cid) else { + return true; + }; if !node.is_element() { return tree.text_content(cid).trim().is_empty(); } @@ -10553,10 +10757,7 @@ fn build_children_with_float_zone( && matches!(style.max_height, crate::Dimension::Auto) && style.margin == crate::Edges::default() && style.padding == crate::Edges::default() - && style - .padding_percent - .iter() - .all(|value| value.is_none()) + && style.padding_percent.iter().all(|value| value.is_none()) && style.border == crate::Edges::default() && style.before_pseudo.is_none() && style.after_pseudo.is_none() @@ -10611,11 +10812,11 @@ fn build_children_with_float_zone( if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) { result.push(row); } - result.extend( - dom_children[opposite_idx + 1..] - .iter() - .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)), - ); + result.extend(dom_children[opposite_idx + 1..].iter().flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + })); return result; } @@ -10647,7 +10848,11 @@ fn build_children_with_float_zone( // Formatting whitespace between floats does not generate an // in-flow flex item or consume horizontal space. .filter(|&&cid| styles.get(&cid).and_then(|s| s.float) == float_side) - .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) + .flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }) .collect(); // A common navigation-bar shape is a run of left floats followed by // one right float. The right float still scans the current float band: @@ -10667,20 +10872,12 @@ fn build_children_with_float_zone( .then(|| dom_children[run_end]); if let Some(right_dom) = trailing_right { let right = build( - tree, - right_dom, - taffy_tree, - id_map, - words, - engine, - ifc_items, - styles, + tree, right_dom, taffy_tree, id_map, words, engine, ifc_items, styles, ); if let Some(right) = right { if let Ok(current) = taffy_tree.style(right) { let mut pushed_right = current.clone(); - pushed_right.margin.left = - taffy::style::LengthPercentageAuto::auto(); + pushed_right.margin.left = taffy::style::LengthPercentageAuto::auto(); let _ = taffy_tree.set_style(right, pushed_right); } run_children.push(right); @@ -10723,7 +10920,14 @@ fn build_children_with_float_zone( result.push(row); } result.extend(build_children_with_float_zone( - tree, parent_id, &dom_children[run_end..], taffy_tree, id_map, words, engine, ifc_items, + tree, + parent_id, + &dom_children[run_end..], + taffy_tree, + id_map, + words, + engine, + ifc_items, styles, )); return result; @@ -10745,7 +10949,9 @@ fn build_children_with_float_zone( // `clear` on a sibling ends the zone: the cleared element moves below the // float (the clearfix idiom), so it must not join the flow column beside it. let clears_this_float = |cid: NodeId| { - let Some(c) = styles.get(&cid).and_then(|s| s.clear) else { return false }; + let Some(c) = styles.get(&cid).and_then(|s| s.clear) else { + return false; + }; match (float_side, c) { (_, crate::Clear::Both) => true, (Some(crate::Float::Left), crate::Clear::Left) => true, @@ -10769,7 +10975,16 @@ fn build_children_with_float_zone( // The float itself is always an element (only elements get style // entries, and `is_float` above required one), so a direct `build` call // is correct here; only its flow siblings need the word-splitting `build_any`. - let float_taffy = build(tree, dom_children[float_idx], taffy_tree, id_map, words, engine, ifc_items, styles); + let float_taffy = build( + tree, + dom_children[float_idx], + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ); // Cap an auto-width float at its widest definite-width descendant so a long // wrappable caption cannot inflate it to the caption's one-line max-content // width and starve the flow column beside it (the Wikipedia-thumbnail / @@ -10778,7 +10993,10 @@ fn build_children_with_float_zone( // (pull quotes, sized infoboxes) are left to normal shrink-to-fit. if let Some(float_id) = float_taffy { let float_dom = dom_children[float_idx]; - let float_auto = styles.get(&float_dom).map(|s| matches!(s.width, crate::Dimension::Auto)).unwrap_or(true); + let float_auto = styles + .get(&float_dom) + .map(|s| matches!(s.width, crate::Dimension::Auto)) + .unwrap_or(true); if float_auto { if let Some(w) = max_definite_descendant_width(tree, float_dom, styles) { if let Ok(cur) = taffy_tree.style(float_id) { @@ -10798,7 +11016,10 @@ fn build_children_with_float_zone( flex_grow: 1.0, flex_shrink: 1.0, flex_basis: taffy::Dimension::length(0.0), - min_size: taffy::Size { width: taffy::Dimension::length(0.0), height: taffy::Dimension::auto() }, + min_size: taffy::Size { + width: taffy::Dimension::length(0.0), + height: taffy::Dimension::auto(), + }, ..Default::default() }; let flow_dom = &dom_children[float_idx + 1..zone_end]; @@ -10875,8 +11096,14 @@ fn build_children_with_float_zone( float_id }; let row_children: Vec = match float_side { - Some(crate::Float::Left) => [Some(row_float), flow_column].into_iter().flatten().collect(), - _ => [flow_column, Some(row_float)].into_iter().flatten().collect(), + Some(crate::Float::Left) => [Some(row_float), flow_column] + .into_iter() + .flatten() + .collect(), + _ => [flow_column, Some(row_float)] + .into_iter() + .flatten() + .collect(), }; if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) { result.push(row); @@ -10898,15 +11125,19 @@ fn build_children_with_float_zone( None => result.extend( dom_children[float_idx + 1..zone_end] .iter() - .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)), + .flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + }), ), } - result.extend( - dom_children[zone_end..] - .iter() - .flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)), - ); + result.extend(dom_children[zone_end..].iter().flat_map(|&cid| { + build_any( + tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles, + ) + })); result } @@ -10922,7 +11153,11 @@ mod tests { ); let laid = layout_dom(&tree, (1280.0, 720.0)); // Every element gets a rect. - assert!(laid.rects.len() >= 4, "expected >=4 element rects, got {}", laid.rects.len()); + assert!( + laid.rects.len() >= 4, + "expected >=4 element rects, got {}", + laid.rects.len() + ); // The two inner divs stack vertically inside the 300px container. let stacks = laid @@ -11042,8 +11277,7 @@ mod tests { let rect = laid.rects[&id]; let frame = sticky.frames.iter().find(|frame| frame.id == id).unwrap(); assert!( - (frame.normal.x - rect.x).abs() < 0.01 - && (frame.normal.y - rect.y).abs() < 0.01, + (frame.normal.x - rect.x).abs() < 0.01 && (frame.normal.y - rect.y).abs() < 0.01, "{name} own transform must not alter its sticky normal position: \ rect={rect:?} frame={frame:?}" ); @@ -11301,8 +11535,7 @@ mod tests { for name in ["flex-percent", "grid-percent"] { let item = rect(name); assert!( - (item.width - 350.0).abs() < 0.01 - && (item.height - 20.0).abs() < 0.01, + (item.width - 350.0).abs() < 0.01 && (item.height - 20.0).abs() < 0.01, "{name}: {item:?}" ); } @@ -11372,9 +11605,7 @@ mod tests { let middle_short = rect("middle-short"); let middle_tall = rect("middle-tall"); assert!( - (middle_short.y + middle_short.height / 2.0 - - middle_tall.y - - middle_tall.height / 2.0) + (middle_short.y + middle_short.height / 2.0 - middle_tall.y - middle_tall.height / 2.0) .abs() < 0.01, "middle-aligned atoms did not share a line center: {middle_short:?} {middle_tall:?}" @@ -11402,8 +11633,7 @@ mod tests { let second = rect(second); assert!((container.width - 200.0).abs() < 0.01, "{container:?}"); assert!( - (first.y - second.y).abs() < 0.01 - && (second.x - first.x - 100.0).abs() < 0.01, + (first.y - second.y).abs() < 0.01 && (second.x - first.x - 100.0).abs() < 0.01, "inner layout was destroyed: first={first:?} second={second:?}" ); } @@ -11475,9 +11705,9 @@ mod tests { .into_iter() .find(|id| { tree.get_node(*id).is_some_and(|node| { - node.as_element() - .is_some_and(|element| element.local.as_ref() == "body") - }) + node.as_element() + .is_some_and(|element| element.local.as_ref() == "body") + }) }) .unwrap(); assert!( @@ -11498,9 +11728,9 @@ mod tests { .into_iter() .find(|id| { tree.get_node(*id).is_some_and(|node| { - node.as_element() - .is_some_and(|element| element.local.as_ref() == "html") - }) + node.as_element() + .is_some_and(|element| element.local.as_ref() == "html") + }) }) .unwrap(); assert!( @@ -11564,7 +11794,10 @@ mod tests { "#, ); let laid = layout_dom(&tree, (80.0, 40.0)); - assert_eq!(laid.scrolling_content_size(&tree, (80.0, 40.0)), (100.0, 50.0)); + assert_eq!( + laid.scrolling_content_size(&tree, (80.0, 40.0)), + (100.0, 50.0) + ); } #[test] @@ -11763,6 +11996,7 @@ mod tests { &HashMap::new(), &[], Some(2), + None, ); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(1.0)); @@ -11775,9 +12009,7 @@ mod tests { #[test] fn ten_level_container_activation_propagates_without_fixed_pass_truncation() { - let mut css = String::from( - "#c0{container:c0/inline-size;width:100px}#target{width:1px}", - ); + let mut css = String::from("#c0{container:c0/inline-size;width:100px}#target{width:1px}"); for level in 0..10 { css.push_str(&format!( "@container c{level} (min-width:100px){{#c{}{{container:c{}/inline-size;width:100px}}}}", @@ -11785,9 +12017,7 @@ mod tests { level + 1 )); } - css.push_str( - "@container c10 (min-width:100px){#target{width:77px}}", - ); + css.push_str("@container c10 (min-width:100px){#target{width:77px}}"); let mut body = String::new(); for level in 0..=10 { body.push_str(&format!("
")); @@ -11830,6 +12060,28 @@ mod tests { ); } + #[test] + fn dormant_container_queries_without_a_container_keep_one_pass_fast_path() { + let tree = parse_html( + r#"
"#, + ); + let (laid, telemetry) = + layout_dom_with_web_fonts_measured(&tree, (800.0, 600.0), &HashMap::new(), &[]); + let target = tree.get_element_by_id("target").unwrap(); + assert_eq!(laid.styles[&target].width, crate::Dimension::Px(25.0)); + assert_eq!( + telemetry, + ContainerLayoutTelemetry { + passes: 1, + termination: ContainerLayoutTermination::NoContainers, + query: crate::css::ContainerQueryStats::default(), + } + ); + } + #[test] fn container_convergence_classifies_self_consistent_stability() { assert_eq!( @@ -11840,10 +12092,7 @@ mod tests { container_iteration_termination(false, &3, Some(&3)), Some(ContainerLayoutTermination::SignatureStable) ); - assert_eq!( - container_iteration_termination(false, &3, Some(&2)), - None - ); + assert_eq!(container_iteration_termination(false, &3, Some(&2)), None); assert_eq!(CONTAINER_LAYOUT_SAFETY_LIMIT, 512); } @@ -12090,7 +12339,10 @@ mod tests { let quote = rect("quote"); let logo = rect("logo"); assert!(quote.width > 2500.0, "{quote:?}"); - assert!((column.width - quote.width).abs() < 0.1, "{column:?} {quote:?}"); + assert!( + (column.width - quote.width).abs() < 0.1, + "{column:?} {quote:?}" + ); assert!((quote.height - 24.0).abs() < 0.1, "{quote:?}"); assert!((logo.x - quote.width - 32.0).abs() < 0.1, "{logo:?}"); } @@ -12123,15 +12375,35 @@ mod tests {
"#, ); let laid = layout_dom(&tree, (1280.0, 720.0)); - for id in ["sheet", "inline-normal", "inline-important", "inline-order", "custom"] { + for id in [ + "sheet", + "inline-normal", + "inline-important", + "inline-order", + "custom", + ] { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); let style = laid.styles.get(&nid).unwrap(); - assert_eq!(style.width, crate::Dimension::Px(320.0), "wrong cascade width for {id}"); + assert_eq!( + style.width, + crate::Dimension::Px(320.0), + "wrong cascade width for {id}" + ); } - for id in ["sheet", "inline-normal", "inline-important", "inline-order", "hint"] { + for id in [ + "sheet", + "inline-normal", + "inline-important", + "inline-order", + "hint", + ] { let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); let style = laid.styles.get(&nid).unwrap(); - assert_eq!(style.background_color, Some([0, 128, 0, 255]), "wrong cascade color for {id}"); + assert_eq!( + style.background_color, + Some([0, 128, 0, 255]), + "wrong cascade color for {id}" + ); } } @@ -12170,6 +12442,41 @@ mod tests { assert_eq!(size("inherited-border").0, 100.0); } + #[test] + fn logical_sizes_control_final_horizontal_geometry() { + let tree = parse_html( + r#" +
+
+
"#, + ); + let laid = layout_dom(&tree, (400.0, 240.0)); + let size = |id: &str| { + let nid = tree.query_selector(&format!("#{id}")).unwrap().unwrap(); + let rect = laid.rects.get(&nid).unwrap(); + (rect.width, rect.height) + }; + assert_eq!(size("logical-last"), (120.0, 30.0)); + assert_eq!(size("physical-last"), (40.0, 15.0)); + assert_eq!(size("bounded"), (300.0, 40.0)); + } + #[test] fn root_percentage_font_size_controls_rem_lengths() { let tree = parse_html( @@ -12443,10 +12750,7 @@ mod tests { style.margin ); } - for (id, size, margin) in [ - ("inherit-size", 20.0, 13.4), - ("initial-size", 16.0, 10.72), - ] { + for (id, size, margin) in [("inherit-size", 20.0, 13.4), ("initial-size", 16.0, 10.72)] { let style = &laid.styles[&tree.get_element_by_id(id).unwrap()]; assert!( (style.font_size.unwrap() - size).abs() < 0.01 @@ -12463,10 +12767,7 @@ mod tests { ] { let style = &laid.styles[&tree.get_element_by_id(id).unwrap()]; assert_eq!(style.margin.top, 0.0, "{id} must lose nested UA margins"); - assert_eq!( - style.margin.bottom, 0.0, - "{id} must lose nested UA margins" - ); + assert_eq!(style.margin.bottom, 0.0, "{id} must lose nested UA margins"); assert_eq!(style.list_style, Some(marker), "{id} marker depth"); } } @@ -12605,7 +12906,11 @@ mod tests { assert_eq!(fragments.len(), 1, "{fragments:?}"); assert_eq!(laid.rects[&token], fragments[0]); assert!(fragments[0].width > 100.0, "{fragments:?}"); - assert_eq!(laid.ifc_items.len(), 1, "the span must stay in one shaped IFC"); + assert_eq!( + laid.ifc_items.len(), + 1, + "the span must stay in one shaped IFC" + ); } #[test] @@ -12626,11 +12931,21 @@ mod tests { let token = tree.get_element_by_id("token").unwrap(); let fragments = &laid.inline_fragments[&token]; - assert_eq!(fragments.len(), 3, "one canonical fragment per visual line: {fragments:?}"); + assert_eq!( + fragments.len(), + 3, + "one canonical fragment per visual line: {fragments:?}" + ); assert!(fragments.windows(2).all(|pair| pair[0].y < pair[1].y)); assert!(fragments.iter().all(|fragment| fragment.width > 0.0)); - let left = fragments.iter().map(|rect| rect.x).fold(f32::INFINITY, f32::min); - let top = fragments.iter().map(|rect| rect.y).fold(f32::INFINITY, f32::min); + let left = fragments + .iter() + .map(|rect| rect.x) + .fold(f32::INFINITY, f32::min); + let top = fragments + .iter() + .map(|rect| rect.y) + .fold(f32::INFINITY, f32::min); let right = fragments .iter() .map(|rect| rect.x + rect.width) @@ -12641,7 +12956,12 @@ mod tests { .fold(f32::NEG_INFINITY, f32::max); assert_eq!( laid.rects[&token], - Rect { x: left, y: top, width: right - left, height: bottom - top } + Rect { + x: left, + y: top, + width: right - left, + height: bottom - top + } ); } @@ -12665,8 +12985,13 @@ mod tests { let local_x = |id: &str, host: &str| rect(id).x - rect(host).x; assert!((rect("token").width - rect("plain").width - 12.0).abs() < 0.01); - assert!((local_x("token", "decorated") - local_x("plain", "plain-host") - 3.0).abs() < 0.01); - assert!((local_x("after", "decorated") - local_x("plain-after", "plain-host") - 20.0).abs() < 0.01); + assert!( + (local_x("token", "decorated") - local_x("plain", "plain-host") - 3.0).abs() < 0.01 + ); + assert!( + (local_x("after", "decorated") - local_x("plain-after", "plain-host") - 20.0).abs() + < 0.01 + ); assert!((rect("after").x - (rect("token").x + rect("token").width) - 5.0).abs() < 0.01); } @@ -12685,8 +13010,14 @@ mod tests { let fragments = &laid.inline_fragments[&token]; assert_eq!(fragments.len(), 3, "{fragments:?}"); - assert!((fragments[0].width - fragments[1].width - 12.0).abs() < 0.01, "{fragments:?}"); - assert!(fragments[2].width > fragments[1].width, "last continuation owns its end side: {fragments:?}"); + assert!( + (fragments[0].width - fragments[1].width - 12.0).abs() < 0.01, + "{fragments:?}" + ); + assert!( + fragments[2].width > fragments[1].width, + "last continuation owns its end side: {fragments:?}" + ); assert!(fragments.iter().all(|fragment| fragment.width <= 70.01)); } @@ -12707,8 +13038,20 @@ mod tests { let laid = layout_dom(&tree, (300.0, 100.0)); let rect = |id: &str| -> Rect { laid.rects[&tree.get_element_by_id(id).unwrap()] }; - assert!((rect("empty").width - 14.0).abs() < 0.01, "{:?}", rect("empty")); - assert!((rect("empty").height - laid.text_engine.inline_font_box_height(&laid.styles[&tree.get_element_by_id("empty").unwrap()]) - 6.0).abs() < 0.01); + assert!( + (rect("empty").width - 14.0).abs() < 0.01, + "{:?}", + rect("empty") + ); + assert!( + (rect("empty").height + - laid.text_engine.inline_font_box_height( + &laid.styles[&tree.get_element_by_id("empty").unwrap()] + ) + - 6.0) + .abs() + < 0.01 + ); let decorated_after = rect("after").x - rect("decorated").x; let plain_after = rect("plain-after").x - rect("plain-host").x; assert!((decorated_after - plain_after - 20.0).abs() < 0.01); @@ -12728,8 +13071,14 @@ mod tests { let fragments = &laid.inline_fragments[&token]; assert_eq!(fragments.len(), 2, "{fragments:?}"); - assert!((fragments[0].width - fragments[1].width).abs() < 0.01, "{fragments:?}"); - assert!((fragments[1].y - fragments[0].y - 24.0).abs() < 0.01, "{fragments:?}"); + assert!( + (fragments[0].width - fragments[1].width).abs() < 0.01, + "{fragments:?}" + ); + assert!( + (fragments[1].y - fragments[0].y - 24.0).abs() < 0.01, + "{fragments:?}" + ); } #[test] @@ -12756,10 +13105,22 @@ mod tests { let pixels = local("pixels", "pixel-host"); let percent = local("percent", "percent-host"); - assert!((pixels.0 - base.0 - 7.0).abs() < 0.01, "{base:?} {pixels:?}"); - assert!((pixels.1 - base.1 - 5.0).abs() < 0.01, "{base:?} {pixels:?}"); - assert!((percent.0 - base.0 - 20.0).abs() < 0.01, "{base:?} {percent:?}"); - assert!((percent.1 - base.1 - 2.0).abs() < 0.01, "{base:?} {percent:?}"); + assert!( + (pixels.0 - base.0 - 7.0).abs() < 0.01, + "{base:?} {pixels:?}" + ); + assert!( + (pixels.1 - base.1 - 5.0).abs() < 0.01, + "{base:?} {pixels:?}" + ); + assert!( + (percent.0 - base.0 - 20.0).abs() < 0.01, + "{base:?} {percent:?}" + ); + assert!( + (percent.1 - base.1 - 2.0).abs() < 0.01, + "{base:?} {percent:?}" + ); } #[test] @@ -12948,7 +13309,10 @@ mod tests { let select = tree.get_element_by_id("language").unwrap(); let rect = laid.rects[&select]; - assert!(rect.width > 120.0, "widest option should set width: {rect:?}"); + assert!( + rect.width > 120.0, + "widest option should set width: {rect:?}" + ); assert!( (18.0..=22.0).contains(&rect.height), "closed native select should have one-line control height: {rect:?}" @@ -12980,15 +13344,30 @@ mod tests { assert_eq!( rect("first"), - Rect { x: 0.0, y: 0.0, width: 100.0, height: 20.0 } + Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 20.0 + } ); assert_eq!( rect("second"), - Rect { x: 100.0, y: 0.0, width: 50.0, height: 20.0 } + Rect { + x: 100.0, + y: 0.0, + width: 50.0, + height: 20.0 + } ); assert_eq!( rect("third"), - Rect { x: 150.0, y: 0.0, width: 50.0, height: 20.0 } + Rect { + x: 150.0, + y: 0.0, + width: 50.0, + height: 20.0 + } ); } @@ -13018,15 +13397,30 @@ mod tests { assert_eq!( rect("first"), - Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 } + Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0 + } ); assert_eq!( rect("second"), - Rect { x: 0.0, y: 100.0, width: 100.0, height: 50.0 } + Rect { + x: 0.0, + y: 100.0, + width: 100.0, + height: 50.0 + } ); assert_eq!( rect("third"), - Rect { x: 0.0, y: 150.0, width: 100.0, height: 50.0 } + Rect { + x: 0.0, + y: 150.0, + width: 100.0, + height: 50.0 + } ); } @@ -13092,19 +13486,39 @@ mod tests { assert_eq!( rect("normal"), - Rect { x: 0.0, y: 0.0, width: 80.0, height: 20.0 } + Rect { + x: 0.0, + y: 0.0, + width: 80.0, + height: 20.0 + } ); assert_eq!( rect("break-word"), - Rect { x: 0.0, y: 20.0, width: 80.0, height: 40.0 } + Rect { + x: 0.0, + y: 20.0, + width: 80.0, + height: 40.0 + } ); assert_eq!( rect("anywhere"), - Rect { x: 0.0, y: 60.0, width: 80.0, height: 40.0 } + Rect { + x: 0.0, + y: 60.0, + width: 80.0, + height: 40.0 + } ); assert_eq!( rect("break-all"), - Rect { x: 0.0, y: 100.0, width: 80.0, height: 40.0 } + Rect { + x: 0.0, + y: 100.0, + width: 80.0, + height: 40.0 + } ); assert_eq!(rect("latin-normal").height, 40.0); assert_eq!(rect("keep-all").height, rect("latin-normal").height); @@ -13142,27 +13556,55 @@ mod tests { let style = |id| &laid.styles[&node(id)]; let height = |id| laid.rects[&node(id)].height; - assert_eq!(style("inherited").overflow_wrap, Some(crate::OverflowWrap::Anywhere)); - assert_eq!(style("inherited").word_break, Some(crate::WordBreak::BreakAll)); - assert_eq!(height("inherited"), 40.0); assert_eq!( - style("inherited").before_pseudo.as_ref().unwrap().overflow_wrap, + style("inherited").overflow_wrap, Some(crate::OverflowWrap::Anywhere) ); assert_eq!( - style("inherited").before_pseudo.as_ref().unwrap().word_break, + style("inherited").word_break, + Some(crate::WordBreak::BreakAll) + ); + assert_eq!(height("inherited"), 40.0); + assert_eq!( + style("inherited") + .before_pseudo + .as_ref() + .unwrap() + .overflow_wrap, + Some(crate::OverflowWrap::Anywhere) + ); + assert_eq!( + style("inherited") + .before_pseudo + .as_ref() + .unwrap() + .word_break, Some(crate::WordBreak::BreakAll) ); - assert_eq!(style("initial").overflow_wrap, Some(crate::OverflowWrap::Normal)); + assert_eq!( + style("initial").overflow_wrap, + Some(crate::OverflowWrap::Normal) + ); assert_eq!(style("initial").word_break, Some(crate::WordBreak::Normal)); assert_eq!(height("initial"), 20.0); for id in ["unset", "inherit", "revert", "revert-layer"] { - assert_eq!(style(id).overflow_wrap, Some(crate::OverflowWrap::Anywhere), "{id}"); - assert_eq!(style(id).word_break, Some(crate::WordBreak::BreakAll), "{id}"); + assert_eq!( + style(id).overflow_wrap, + Some(crate::OverflowWrap::Anywhere), + "{id}" + ); + assert_eq!( + style(id).word_break, + Some(crate::WordBreak::BreakAll), + "{id}" + ); assert_eq!(height(id), 40.0, "{id}"); } - assert_eq!(style("alias").overflow_wrap, Some(crate::OverflowWrap::Anywhere)); + assert_eq!( + style("alias").overflow_wrap, + Some(crate::OverflowWrap::Anywhere) + ); assert_eq!(style("alias").word_break, Some(crate::WordBreak::Normal)); assert_eq!(height("alias"), 40.0); } @@ -13299,5 +13741,4 @@ mod tests { assert!(width("keep") > width("normal")); } - } diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index db2e3f6..341ebe5 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -15,9 +15,9 @@ use std::{collections::HashMap, sync::Arc}; use cosmic_text::{ - Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap, Cursor, - CssWordBreak, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics, Shaping, - Style, SwashCache, SwashImage, VariationTag, Weight, Wrap, + Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, CssLineBreak, CssOverflowWrap, + CssWordBreak, Cursor, Family, FeatureTag, FontFeatures, FontSystem, FontVariations, Metrics, + Shaping, Style, SwashCache, SwashImage, VariationTag, Weight, Wrap, }; use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith}; use swash::zeno::{Angle, Format, Transform, Vector}; @@ -190,41 +190,41 @@ fn select_loaded_face( requested_weight: u16, requested_italic: bool, ) -> Option { - let exact_style: Vec<_> = family - .faces - .iter() - .filter(|face| face.italic == requested_italic) - .collect(); - let candidates: Vec<_> = if exact_style.is_empty() { - family.faces.iter().collect() - } else { - exact_style - }; - if let Some(face) = candidates - .iter() - .copied() - .find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight)) - { + let exact_style: Vec<_> = family + .faces + .iter() + .filter(|face| face.italic == requested_italic) + .collect(); + let candidates: Vec<_> = if exact_style.is_empty() { + family.faces.iter().collect() + } else { + exact_style + }; + if let Some(face) = candidates + .iter() + .copied() + .find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight)) + { // The named-family matcher uses fontdb's default weight for this // resource, while a variable face commonly advertises `100 900` in // CSS. Preserve the descriptor-selected file and its database weight; // the authored coordinate enters the canonical axis tuple below. return Some(ResolvedFont { - family: Arc::clone(&face.name), - font_id: face.font_id, - metrics: face.metrics, + family: Arc::clone(&face.name), + font_id: face.font_id, + metrics: face.metrics, synthetic_italic: requested_italic && !face.italic, }); - } + } let available: Vec<_> = candidates.iter().map(|face| face.min_weight).collect(); - let matched = match_font_weight(requested_weight, &available); + let matched = match_font_weight(requested_weight, &available); candidates - .into_iter() - .find(|face| face.min_weight == matched) + .into_iter() + .find(|face| face.min_weight == matched) .map(|face| ResolvedFont { - family: Arc::clone(&face.name), - font_id: face.font_id, - metrics: face.metrics, + family: Arc::clone(&face.name), + font_id: face.font_id, + metrics: face.metrics, synthetic_italic: requested_italic && !face.italic, }) } @@ -247,7 +247,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 { .copied() .filter(|weight| *weight >= requested && *weight <= 500) .min() - .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) + .or_else(|| { + weights + .iter() + .copied() + .filter(|weight| *weight < requested) + .max() + }) .or_else(|| weights.iter().copied().filter(|weight| *weight > 500).min()) .unwrap_or(requested) } else if requested < 400 { @@ -256,7 +262,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 { .copied() .filter(|weight| *weight <= requested) .max() - .or_else(|| weights.iter().copied().filter(|weight| *weight > requested).min()) + .or_else(|| { + weights + .iter() + .copied() + .filter(|weight| *weight > requested) + .min() + }) .unwrap_or(requested) } else { weights @@ -264,7 +276,13 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 { .copied() .filter(|weight| *weight >= requested) .min() - .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) + .or_else(|| { + weights + .iter() + .copied() + .filter(|weight| *weight < requested) + .max() + }) .unwrap_or(requested) } } @@ -309,7 +327,10 @@ fn normal_line_height(font_size: f32, metrics: FaceMetrics) -> f32 { /// border. Chromium and Gecko both fit ascent and descent independently. fn fitted_font_box_metrics(font_size: f32, metrics: FaceMetrics) -> (f32, f32) { let scale = font_size / metrics.units_per_em.max(1.0); - ((metrics.ascent * scale).round(), (metrics.descent * scale).round()) + ( + (metrics.ascent * scale).round(), + (metrics.descent * scale).round(), + ) } fn font_metrics( @@ -365,8 +386,7 @@ const META_UNDERLINE: usize = 1; const META_FILL_SHIFT: usize = 1; const META_VARIATION_BITS: usize = usize::BITS as usize / 2; const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS; -const META_VARIATION_MASK: usize = - ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT; +const META_VARIATION_MASK: usize = ((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT; const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE; fn metadata_fill(metadata: usize) -> Option { @@ -604,9 +624,7 @@ impl VariableSwashCache { let explicit_value = key.explicit.as_ref().and_then(|settings| { settings .iter() - .find(|setting| { - swash::tag_from_bytes(setting.tag.as_bytes()) == tag - }) + .find(|setting| swash::tag_from_bytes(setting.tag.as_bytes()) == tag) .map(|setting| setting.value.0) }); let automatic_value = if tag == swash::tag_from_bytes(b"wght") { @@ -675,12 +693,7 @@ impl VariableSwashCache { cache_key .flags .contains(CacheKeyFlags::FAKE_ITALIC) - .then(|| { - Transform::skew( - Angle::from_degrees(14.0), - Angle::from_degrees(0.0), - ) - }), + .then(|| Transform::skew(Angle::from_degrees(14.0), Angle::from_degrees(0.0))), ) .render(&mut scaler, cache_key.glyph_id) }); @@ -747,10 +760,10 @@ impl ReplacedItem { }; let intrinsic_ratio = if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { - width / height - } else { - 1.0 - }; + width / height + } else { + 1.0 + }; ReplacedItem { intrinsic_width: width, preferred_width: px(style.width), @@ -827,14 +840,8 @@ impl ReplacedItem { fn size(self, known: taffy::Size>) -> taffy::Size { let (width, height) = match (known.width, known.height) { (Some(width), Some(height)) => (width, height), - (Some(width), None) => ( - width, - width / self.preferred_ratio, - ), - (None, Some(height)) => ( - height * self.preferred_ratio, - height, - ), + (Some(width), None) => (width, width / self.preferred_ratio), + (None, Some(height)) => (height * self.preferred_ratio, height), (None, None) => match (self.preferred_width, self.preferred_height) { (Some(width), Some(height)) => (width, height), (Some(width), None) => (width, width / self.preferred_ratio), @@ -913,12 +920,7 @@ impl TextEngine { for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( font.data.clone(), ))) { - declarations.push(( - id, - font.family.clone(), - font.weight, - font.italic, - )); + declarations.push((id, font.family.clone(), font.weight, font.italic)); } } let mut loaded_families = HashMap::new(); @@ -1018,7 +1020,12 @@ impl TextEngine { /// item index to store as the leaf's taffy context. `None` means the /// container is not a pure-text IFC and should build through the normal /// (block / flex / word-split) path. - pub fn try_build(&mut self, tree: &DomTree, id: NodeId, styles: &std::collections::HashMap) -> Option { + pub fn try_build( + &mut self, + tree: &DomTree, + id: NodeId, + styles: &std::collections::HashMap, + ) -> Option { if !is_pure_text_ifc(tree, id, styles) { return None; } @@ -1116,11 +1123,7 @@ impl TextEngine { /// weight selection, transformations, and glyph rasterizer as an ordinary /// inline formatting context. The caller measures/finalizes/paints the /// returned item immediately against the pseudo's resolved content box. - pub(crate) fn push_generated_text( - &mut self, - text: &str, - style: &LayoutStyle, - ) -> Option { + pub(crate) fn push_generated_text(&mut self, text: &str, style: &LayoutStyle) -> Option { let mut collector = Collector::new(); let font = resolve_loaded_font( style.font_family.as_deref(), @@ -1183,7 +1186,10 @@ impl TextEngine { mut boundary_events: Vec, ) -> Option { let white_space = base.white_space.unwrap_or_default(); - let layout_wrap = if spans.iter().any(|(_, attrs)| attrs.has_layout_emergency_breaks()) { + let layout_wrap = if spans + .iter() + .any(|(_, attrs)| attrs.has_layout_emergency_breaks()) + { Wrap::WordOrGlyph } else { Wrap::Word @@ -1199,9 +1205,7 @@ impl TextEngine { // Collapsible trailing whitespace does not widen the last line. if matches!( white_space, - crate::WhiteSpace::Normal - | crate::WhiteSpace::NoWrap - | crate::WhiteSpace::PreLine + crate::WhiteSpace::Normal | crate::WhiteSpace::NoWrap | crate::WhiteSpace::PreLine ) { if let Some((text, _)) = spans.last_mut() { if text.ends_with(' ') { @@ -1209,7 +1213,8 @@ impl TextEngine { } } } - if owner_boxes.is_empty() && spans.iter().all(|(text, _)| { + if owner_boxes.is_empty() + && spans.iter().all(|(text, _)| { text.is_empty() || (matches!( white_space, @@ -1218,7 +1223,8 @@ impl TextEngine { | crate::WhiteSpace::PreLine ) && text.trim().is_empty() && !text.contains('\n')) - }) { + }) + { return None; } let text_len = spans.iter().map(|(text, _)| text.len()).sum::(); @@ -1294,13 +1300,12 @@ impl TextEngine { } } } - let line_clamp = (base.webkit_box_display.is_some() - && base.webkit_box_orient_vertical) + let line_clamp = (base.webkit_box_display.is_some() && base.webkit_box_orient_vertical) .then_some(base.webkit_line_clamp) .flatten() .map(|lines| lines as usize); - let ellipsis_overflow = base.text_overflow == crate::TextOverflow::Ellipsis - && base.clips_overflow_x(); + let ellipsis_overflow = + base.text_overflow == crate::TextOverflow::Ellipsis && base.clips_overflow_x(); let marker_attrs = (line_clamp.is_some() || ellipsis_overflow) .then(|| spans.last().map(|(_, attrs)| attrs.clone())) .flatten(); @@ -1367,7 +1372,7 @@ impl TextEngine { let text_indent = base.text_indent.unwrap_or(Dimension::Px(0.0)); let source_buffer = (!matches!(text_indent, Dimension::Px(value) if value == 0.0) || !boundary_events.is_empty()) - .then(|| buffer.clone()); + .then(|| buffer.clone()); self.items.push(InlineItem { buffer, layout_wrap, @@ -1400,21 +1405,20 @@ impl TextEngine { /// taffy's measure function during layout. pub fn measure(&mut self, idx: usize, width: Option) -> (f32, f32) { if idx & REPLACED_CONTEXT_BIT != 0 { - let size = self.replaced[idx & !REPLACED_CONTEXT_BIT] - .size(taffy::Size { width, height: None }); + let size = self.replaced[idx & !REPLACED_CONTEXT_BIT].size(taffy::Size { + width, + height: None, + }); return (size.width, size.height); } let wrap = self.items[idx].layout_wrap; self.measure_text_with_wrap(idx, width, wrap) } - fn measure_text_with_wrap( - &mut self, - idx: usize, - width: Option, - wrap: Wrap, - ) -> (f32, f32) { - let TextEngine { font_system, items, .. } = self; + fn measure_text_with_wrap(&mut self, idx: usize, width: Option, wrap: Wrap) -> (f32, f32) { + let TextEngine { + font_system, items, .. + } = self; let item = &mut items[idx]; shape_with_text_indent(font_system, item, width, wrap); let (width, height, clamped) = buffer_size(item); @@ -1467,12 +1471,7 @@ impl TextEngine { /// Register a replaced element's intrinsic size as a taffy measure /// context. Percentage-sized image leaves still need their intrinsic /// max-content contribution while an auto-sized ancestor is measured. - pub fn register_replaced( - &mut self, - width: f32, - height: f32, - style: &LayoutStyle, - ) -> usize { + pub fn register_replaced(&mut self, width: f32, height: f32, style: &LayoutStyle) -> usize { let index = self.replaced.len(); self.replaced .push(ReplacedItem::from_style(width, height, style)); @@ -1625,10 +1624,7 @@ impl TextEngine { /// DOM resolves percentages against the real containing block. Selecting /// the deepest matching range at paint time is therefore sufficient even /// for nested relative inlines: its offset already includes its ancestors. - pub(crate) fn set_inline_owner_offsets( - &mut self, - offsets: &HashMap, - ) { + pub(crate) fn set_inline_owner_offsets(&mut self, offsets: &HashMap) { for item in &mut self.items { item.relative_owner_ranges = item .owner_ranges @@ -1694,12 +1690,7 @@ impl TextEngine { }; let raw_right = if last { run_cursor_x(&run, owner.end.saturating_sub(line_start)) - + line_advance_before_event( - item, - owner.end_event, - line_start, - line_end, - ) + + line_advance_before_event(item, owner.end_event, line_start, line_end) + owner.end_edge.border_padding() } else { run.line_w + line_edge_advance(item, line_start, line_end) @@ -1817,7 +1808,11 @@ impl SpanAttrs { } // Clip-text glyphs must be shaped with an opaque fill so their coverage // reaches paint; the real gradient is selected through metadata. - let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color }; + let color = if self.clip_fill.is_some() { + [255, 255, 255, 255] + } else { + self.color + }; a = a.color(Color::rgba(color[0], color[1], color[2], color[3])); let fill = self .clip_fill @@ -1986,11 +1981,7 @@ fn resolved_font_variations( (!variations.is_empty()).then(|| Arc::new(variations)) } -fn base_span_ctx( - base: &LayoutStyle, - font: ResolvedFont, - collector: &mut Collector, -) -> SpanCtx { +fn base_span_ctx(base: &LayoutStyle, font: ResolvedFont, collector: &mut Collector) -> SpanCtx { let clip_fill = clip_text_fill(base).map(|fill| { let index = collector.clip_fills.len(); collector.clip_fills.push(fill); @@ -2031,15 +2022,7 @@ fn collect_spans( loaded_families: &HashMap, ) { for cid in crate::dom::rendered_children(tree, id) { - collect_node_spans( - tree, - cid, - styles, - ctx.clone(), - out, - c, - loaded_families, - ); + collect_node_spans(tree, cid, styles, ctx.clone(), out, c, loaded_families); } } @@ -2056,7 +2039,9 @@ fn collect_node_spans( c: &mut Collector, loaded_families: &HashMap, ) { - let Some(node) = tree.get_node(cid) else { return }; + let Some(node) = tree.get_node(cid) else { + return; + }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { let attrs = SpanAttrs { @@ -2078,17 +2063,12 @@ fn collect_node_spans( overflow_wrap: ctx.overflow_wrap, word_break: ctx.word_break, }; - push_text( - contents, - ctx.transform, - ctx.white_space, - &attrs, - out, - c, - ); + push_text(contents, ctx.transform, ctx.white_space, &attrs, out, c); } _ => { - let Some(elem) = node.as_element() else { return }; + let Some(elem) = node.as_element() else { + return; + }; let style = styles.get(&cid); if style.map(|s| s.display == Display::None).unwrap_or(false) { return; @@ -2097,32 +2077,31 @@ fn collect_node_spans( out.push(( "\n".to_string(), SpanAttrs { - font_size: ctx.font_size, - line_height: ctx.line_height, - letter_spacing: ctx.letter_spacing, - letter_spacing_non_normal: ctx.letter_spacing_non_normal, - weight: ctx.weight, - optical_sizing: ctx.optical_sizing, - font_id: ctx.font_id, - variations: ctx.variations.clone(), - italic: ctx.italic, + font_size: ctx.font_size, + line_height: ctx.line_height, + letter_spacing: ctx.letter_spacing, + letter_spacing_non_normal: ctx.letter_spacing_non_normal, + weight: ctx.weight, + optical_sizing: ctx.optical_sizing, + font_id: ctx.font_id, + variations: ctx.variations.clone(), + italic: ctx.italic, synthetic_italic: ctx.synthetic_italic, - underline: ctx.underline, - color: ctx.color, - family: Arc::clone(&ctx.family), - clip_fill: ctx.clip_fill, - white_space: ctx.white_space, - overflow_wrap: ctx.overflow_wrap, - word_break: ctx.word_break, + underline: ctx.underline, + color: ctx.color, + family: Arc::clone(&ctx.family), + clip_fill: ctx.clip_fill, + white_space: ctx.white_space, + overflow_wrap: ctx.overflow_wrap, + word_break: ctx.word_break, }, )); c.text_len = c.text_len.saturating_add(1); c.last_was_space = true; return; } - let owns_inline_fragment = style.is_some_and(|style| { - style.ignores_used_box_sizes() && !style.display_contents - }); + let owns_inline_fragment = style + .is_some_and(|style| style.ignores_used_box_sizes() && !style.display_contents); if owns_inline_fragment { c.begin_owner(cid, style.expect("inline owner style")); } @@ -2135,9 +2114,8 @@ fn collect_node_spans( // A descendant with its own clip-text background replaces the // inherited fill. Transparent descendants otherwise continue an // ancestor's fill; an opaque text color paints normally. - let clip_fill = own_clip_fill.or_else(|| { - if color[3] == 0 { ctx.clip_fill } else { None } - }); + let clip_fill = + own_clip_fill.or_else(|| if color[3] == 0 { ctx.clip_fill } else { None }); let requested_weight = style .map(crate::style::used_font_weight) .unwrap_or(ctx.weight); @@ -2188,10 +2166,10 @@ fn collect_node_spans( // Underline propagates in: an ancestor's underline covers // descendant text; an element only sets its own via CSS. underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), - transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), - white_space: style - .and_then(|s| s.white_space) - .unwrap_or(ctx.white_space), + transform: style + .and_then(|s| s.text_transform) + .unwrap_or(ctx.transform), + white_space: style.and_then(|s| s.white_space).unwrap_or(ctx.white_space), overflow_wrap: style .and_then(|s| s.overflow_wrap) .unwrap_or(ctx.overflow_wrap), @@ -2199,15 +2177,7 @@ fn collect_node_spans( family: font.family, clip_fill, }; - collect_spans( - tree, - cid, - styles, - child, - out, - c, - loaded_families, - ); + collect_spans(tree, cid, styles, child, out, c, loaded_families); if owns_inline_fragment { c.end_owner(cid); } @@ -2366,7 +2336,11 @@ fn buffer_size(item: &InlineItem) -> (f32, f32, bool) { let clamped = item.line_clamp.is_some_and(|limit| nonempty_lines > limit); ( w.ceil(), - if clamped { clamp_height.unwrap_or(h) } else { h }, + if clamped { + clamp_height.unwrap_or(h) + } else { + h + }, clamped, ) } @@ -2437,7 +2411,11 @@ fn used_text_indent(value: Dimension, width: Option) -> f32 { Dimension::Percent(fraction) => width.unwrap_or(0.0) * fraction, _ => 0.0, }; - if indent.is_finite() { indent } else { 0.0 } + if indent.is_finite() { + indent + } else { + 0.0 + } } /// Shape one IFC with first-line indent and ordinary-inline boundary advances. @@ -2490,9 +2468,7 @@ fn shape_with_text_indent( let negative_edges = item .boundary_events .iter() - .filter(|event| { - event.position >= global_start && event.position <= line_source_end - }) + .filter(|event| event.position >= global_start && event.position <= line_source_end) .map(|event| event.edge.advance().min(0.0)) .sum::(); let mut available = (base_available - negative_edges).max(0.0); @@ -2512,10 +2488,7 @@ fn shape_with_text_indent( let Some(first) = layouts.first() else { break; }; - ( - first.glyphs.iter().map(|glyph| glyph.end).max(), - first.w, - ) + (first.glyphs.iter().map(|glyph| glyph.end).max(), first.w) }; let Some(candidate) = candidate else { break; @@ -2689,8 +2662,19 @@ fn is_pure_text_ifc( pub(crate) fn is_replaced(local: &str) -> bool { matches!( local, - "img" | "svg" | "canvas" | "video" | "audio" | "iframe" | "embed" - | "object" | "input" | "textarea" | "select" | "button" | "progress" + "img" + | "svg" + | "canvas" + | "video" + | "audio" + | "iframe" + | "embed" + | "object" + | "input" + | "textarea" + | "select" + | "button" + | "progress" | "meter" ) } @@ -2702,8 +2686,7 @@ pub(crate) fn is_replaced(local: &str) -> bool { pub(crate) fn has_replaced_sizing(local: &str) -> bool { matches!( local, - "img" | "canvas" | "video" | "audio" | "iframe" | "embed" - | "object" | "progress" | "meter" + "img" | "canvas" | "video" | "audio" | "iframe" | "embed" | "object" | "progress" | "meter" ) } @@ -2737,8 +2720,15 @@ pub(crate) fn default_replaced_intrinsic_size( /// genuinely cannot fold are rejected: replaced/atomic elements, block-level /// children, floats, out-of-flow positioned boxes, and elements with generated /// content (which would be lost). -fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashMap, has_text: &mut bool) -> bool { - let Some(node) = tree.get_node(cid) else { return true }; +fn inline_child_ok( + tree: &DomTree, + cid: NodeId, + styles: &std::collections::HashMap, + has_text: &mut bool, +) -> bool { + let Some(node) = tree.get_node(cid) else { + return true; + }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { if !contents.trim().is_empty() { @@ -2747,8 +2737,12 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM true } obscura_dom::tree::NodeData::Element { .. } => { - let Some(elem) = node.as_element() else { return true }; - let Some(style) = styles.get(&cid) else { return false }; + let Some(elem) = node.as_element() else { + return true; + }; + let Some(style) = styles.get(&cid) else { + return false; + }; if style.display == Display::None { return true; // removed from flow; ignore its subtree } @@ -2794,11 +2788,15 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM /// Content-box origin for a container whose border box is `rect`, i.e. inside /// its border and padding: where inline text actually starts. pub fn content_origin(rect: &Rect, style: &LayoutStyle) -> (f32, f32) { - (rect.x + style.border.left + style.padding.left, rect.y + style.border.top + style.padding.top) + ( + rect.x + style.border.left + style.padding.left, + rect.y + style.border.top + style.padding.top, + ) } pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 { - (rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right).max(0.0) + (rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right) + .max(0.0) } /// The background to paint through the glyphs for `-webkit-background-clip: text` @@ -2829,7 +2827,13 @@ fn clip_text_fill(style: &LayoutStyle) -> Option<(f32, Vec<([u8; 4], Option /// returning an rgba color. `angle` is CSS degrees clockwise from 12 o'clock /// (0 = to top, 90 = to right, 180 = to bottom), matching `parse_linear_gradient` /// and `paint::paint_linear_gradient`. Positionless stops are spread evenly. -fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option)>), x: f32, y: f32, w: f32, h: f32) -> [u8; 4] { +fn sample_gradient( + fill: &(f32, Vec<([u8; 4], Option)>), + x: f32, + y: f32, + w: f32, + h: f32, +) -> [u8; 4] { let (angle, stops) = fill; match stops.len() { 0 => return [0, 0, 0, 255], @@ -2848,7 +2852,12 @@ fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option)>), x: f32, y: f32, w: (((x - w / 2.0) * dx + (y - h / 2.0) * dy) / len + 0.5).clamp(0.0, 1.0) }; let n = stops.len(); - let pos = |i: usize| stops[i].1.unwrap_or(i as f32 / (n as f32 - 1.0)).clamp(0.0, 1.0); + let pos = |i: usize| { + stops[i] + .1 + .unwrap_or(i as f32 / (n as f32 - 1.0)) + .clamp(0.0, 1.0) + }; // Walk to the pair of stops surrounding t, then interpolate between them. let mut lo = 0usize; while lo + 1 < n && pos(lo + 1) < t { @@ -2856,10 +2865,23 @@ fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option)>), x: f32, y: f32, w: } let hi = (lo + 1).min(n - 1); let (p0, p1) = (pos(lo), pos(hi)); - let f = if (p1 - p0).abs() < 1e-6 { 0.0 } else { ((t - p0) / (p1 - p0)).clamp(0.0, 1.0) }; + let f = if (p1 - p0).abs() < 1e-6 { + 0.0 + } else { + ((t - p0) / (p1 - p0)).clamp(0.0, 1.0) + }; let (c0, c1) = (stops[lo].0, stops[hi].0); - let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f).round().clamp(0.0, 255.0) as u8; - [lerp(c0[0], c1[0]), lerp(c0[1], c1[1]), lerp(c0[2], c1[2]), lerp(c0[3], c1[3])] + let lerp = |a: u8, b: u8| { + (a as f32 + (b as f32 - a as f32) * f) + .round() + .clamp(0.0, 255.0) as u8 + }; + [ + lerp(c0[0], c1[0]), + lerp(c0[1], c1[1]), + lerp(c0[2], c1[2]), + lerp(c0[3], c1[3]), + ] } impl TextEngine { @@ -2880,6 +2902,36 @@ impl TextEngine { pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32), clip_override: Option, + ) { + self.paint_item_with_clip_mask(idx, pixmap, offset, clip_override, None); + } + + /// Rasterize shaped text against the rectangular culling envelope and an + /// optional full descendant clip-chain mask. The latter preserves rounded + /// overflow corners without moving glyph coordinates into the clip + /// owner's space. + pub fn paint_item_with_clip_mask( + &mut self, + idx: usize, + pixmap: &mut tiny_skia::Pixmap, + offset: (f32, f32), + clip_override: Option, + clip_mask: Option<&tiny_skia::Mask>, + ) { + self.paint_item_with_clip_mask_scaled(idx, pixmap, offset, clip_override, clip_mask, 1.0); + } + + /// Rasterize already-shaped CSS-pixel glyph positions directly into a + /// device-pixel surface. Shaping and line breaking stay immutable; only + /// glyph outline sampling, clipping, and compositing use `raster_scale`. + pub fn paint_item_with_clip_mask_scaled( + &mut self, + idx: usize, + pixmap: &mut tiny_skia::Pixmap, + offset: (f32, f32), + clip_override: Option, + clip_mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let TextEngine { font_system, @@ -2888,8 +2940,13 @@ impl TextEngine { items, .. } = self; - let Some(item) = items.get_mut(idx) else { return }; - let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1); + let Some(item) = items.get_mut(idx) else { + return; + }; + let (ox, oy) = ( + (item.origin.0 + offset.0) * raster_scale, + (item.origin.1 + offset.1) * raster_scale, + ); // The glyph origin shifts by the container's accumulated translate, // but the clip is already in screen space (owner-shifted at // `resolve_clip_rects`) and must not move with the container, or a @@ -2897,13 +2954,18 @@ impl TextEngine { let clip = clip_override.or(item.clip); let pw = pixmap.width() as i32; let ph = pixmap.height() as i32; - let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height)); + let clip_bounds = clip.map(|c| { + ( + c.x * raster_scale, + c.y * raster_scale, + (c.x + c.width) * raster_scale, + (c.y + c.height) * raster_scale, + ) + }); let line_source_starts = item .owner_text .as_deref() - .filter(|_| { - !item.relative_owner_ranges.is_empty() || !item.boundary_events.is_empty() - }) + .filter(|_| !item.relative_owner_ranges.is_empty() || !item.boundary_events.is_empty()) .map(|source| source_line_starts(&item.buffer, source)) .unwrap_or_default(); @@ -2912,8 +2974,7 @@ impl TextEngine { // consecutive underlined glyphs on a line into one stroke below the // baseline. Done first so the draw() mutable borrow does not overlap. let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color - let mut fill_bounds: Vec> = - vec![None; item.clip_fills.len()]; + let mut fill_bounds: Vec> = vec![None; item.clip_fills.len()]; for (line_index, run) in item.buffer.layout_runs().enumerate() { let line_offset = if line_index == 0 { item.first_line_offset @@ -2932,8 +2993,7 @@ impl TextEngine { // underline tail or expand a gradient's sampling bounds past // the separately painted ellipsis marker. if item.marker.is_some_and(|marker| { - marker.line_index == line_index - && g.x + line_offset + g.w > marker.content_end + marker.line_index == line_index && g.x + line_offset + g.w > marker.content_end }) { continue; } @@ -2970,7 +3030,10 @@ impl TextEngine { }); } } - let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]); + let col = g + .color_opt + .map(|c| [c.r(), c.g(), c.b(), c.a()]) + .unwrap_or([0, 0, 0, 255]); if underlined { match &mut seg { Some((_, x1, fs, c, prior_relative)) @@ -2981,7 +3044,13 @@ impl TextEngine { } _ => { if let Some((x0, x1, fs, c, prior_relative)) = seg.take() { - underlines.push((x0, x1, base_y + prior_relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); + underlines.push(( + x0, + x1, + base_y + prior_relative.1 + (fs * 0.12).max(1.0), + (fs / 14.0).max(1.0), + c, + )); } seg = Some(( g.x + line_offset + relative.0, @@ -2993,11 +3062,23 @@ impl TextEngine { } } } else if let Some((x0, x1, fs, c, relative)) = seg.take() { - underlines.push((x0, x1, base_y + relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); + underlines.push(( + x0, + x1, + base_y + relative.1 + (fs * 0.12).max(1.0), + (fs / 14.0).max(1.0), + c, + )); } } if let Some((x0, x1, fs, c, relative)) = seg.take() { - underlines.push((x0, x1, base_y + relative.1 + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c)); + underlines.push(( + x0, + x1, + base_y + relative.1 + (fs * 0.12).max(1.0), + (fs / 14.0).max(1.0), + c, + )); } } @@ -3041,10 +3122,7 @@ impl TextEngine { line_source_start, line_source_end, ); - let physical = glyph.physical( - (line_offset + relative.0, relative.1), - 1.0, - ); + let physical = glyph.physical((line_offset + relative.0, relative.1), raster_scale); let glyph_color = glyph.color_opt.unwrap_or(default); let fill_index = metadata_fill(glyph.metadata); let mut draw_pixel = |x, y, color: Color| { @@ -3057,15 +3135,15 @@ impl TextEngine { return; } let gx = physical.x + x; - let gy = run.line_y as i32 + physical.y + y; + let gy = (run.line_y * raster_scale) as i32 + physical.y + y; let (r, g, b) = fill_index .and_then(|index| { let fill = clip_fills.get(index)?; let (x0, y0, x1, y1) = fill_bounds.get(index).copied().flatten()?; let sampled = sample_gradient( fill, - gx as f32 + 0.5 - x0, - gy as f32 + 0.5 - y0, + (gx as f32 + 0.5) / raster_scale - x0, + (gy as f32 + 0.5) / raster_scale - y0, x1 - x0, y1 - y0, ); @@ -3075,7 +3153,11 @@ impl TextEngine { let px = ox as i32 + gx; let py = oy as i32 + gy; if let Some((cx0, cy0, cx1, cy1)) = clip_bounds { - if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 { + if (px as f32) < cx0 + || (px as f32) >= cx1 + || (py as f32) < cy0 + || (py as f32) >= cy1 + { return; } } @@ -3083,6 +3165,14 @@ impl TextEngine { return; } let idx = (py * pw + px) as usize; + let mask_alpha = clip_mask + .and_then(|mask| mask.data().get(idx)) + .copied() + .unwrap_or(255) as u32; + let a = a * mask_alpha / 255; + if a == 0 { + return; + } let dst = pixels[idx]; let sa = a; let sr = (r as u32 * sa) / 255; @@ -3102,7 +3192,7 @@ impl TextEngine { out_b as u8, out_a as u8, ) - .unwrap_or(dst); + .unwrap_or(dst); }; let explicit_variations = metadata_variation(glyph.metadata) .and_then(|index| item.variation_sets.get(index)) @@ -3110,14 +3200,14 @@ impl TextEngine { let effective_variations = glyph .font_is_variable .then(|| { - variable_swash.effective_variations( - font_system, - physical.cache_key.font_id, - glyph.font_weight_axis_opt, - glyph.font_optical_size_opt, - glyph.font_italic_axis, - explicit_variations, - ) + variable_swash.effective_variations( + font_system, + physical.cache_key.font_id, + glyph.font_weight_axis_opt, + glyph.font_optical_size_opt, + glyph.font_italic_axis, + explicit_variations, + ) }) .flatten(); if let Some(variations) = effective_variations { @@ -3167,12 +3257,9 @@ impl TextEngine { let dst = pixels[index]; let inverse = 255 - alpha; let out_alpha = alpha + dst.alpha() as u32 * inverse / 255; - let out_red = color.r() as u32 * alpha / 255 - + dst.red() as u32 * inverse / 255; - let out_green = color.g() as u32 * alpha / 255 - + dst.green() as u32 * inverse / 255; - let out_blue = color.b() as u32 * alpha / 255 - + dst.blue() as u32 * inverse / 255; + let out_red = color.r() as u32 * alpha / 255 + dst.red() as u32 * inverse / 255; + let out_green = color.g() as u32 * alpha / 255 + dst.green() as u32 * inverse / 255; + let out_blue = color.b() as u32 * alpha / 255 + dst.blue() as u32 * inverse / 255; pixels[index] = tiny_skia::PremultipliedColorU8::from_rgba( out_red as u8, out_green as u8, @@ -3189,9 +3276,9 @@ impl TextEngine { if col[3] == 0 { continue; } - let t = thick.max(1.0).round() as i32; + let t = (thick.max(1.0) * raster_scale).round().max(1.0) as i32; for dt in 0..t { - let py = oy as i32 + y as i32 + dt; + let py = oy as i32 + (y * raster_scale) as i32 + dt; if py < 0 || py >= ph { continue; } @@ -3200,7 +3287,7 @@ impl TextEngine { continue; } } - for px in (ox + x0) as i32..(ox + x1) as i32 { + for px in (ox + x0 * raster_scale) as i32..(ox + x1 * raster_scale) as i32 { if px < 0 || px >= pw { continue; } @@ -3214,12 +3301,9 @@ impl TextEngine { let sa = col[3] as u32; let inv = 255 - sa; let out_a = sa + dst.alpha() as u32 * inv / 255; - let out_r = col[0] as u32 * sa / 255 - + dst.red() as u32 * inv / 255; - let out_g = col[1] as u32 * sa / 255 - + dst.green() as u32 * inv / 255; - let out_b = col[2] as u32 * sa / 255 - + dst.blue() as u32 * inv / 255; + let out_r = col[0] as u32 * sa / 255 + dst.red() as u32 * inv / 255; + let out_g = col[1] as u32 * sa / 255 + dst.green() as u32 * inv / 255; + let out_b = col[2] as u32 * sa / 255 + dst.blue() as u32 * inv / 255; pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba( out_r as u8, out_g as u8, @@ -3336,10 +3420,7 @@ mod tests { normal_line_height(9.3333, bundled_face_metrics(FAMILY)), 10.0 ); - assert_eq!( - normal_line_height(12.0, bundled_face_metrics(FAMILY)), - 14.0 - ); + assert_eq!(normal_line_height(12.0, bundled_face_metrics(FAMILY)), 14.0); assert_eq!( normal_line_height(13.0, bundled_face_metrics(SERIF_FAMILY)), 16.0 @@ -3373,8 +3454,7 @@ mod tests { weight: Some((400, 400)), italic: Some(false), }]); - let resolved = - resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families); + let resolved = resolve_loaded_font(Some("Page Face"), 400, false, &engine.loaded_families); let expected = font_metrics(&engine.font_system.db(), resolved.font_id.unwrap()).unwrap(); assert_eq!(resolved.metrics, expected); @@ -3395,8 +3475,7 @@ mod tests { for expression_index in [0, 4] { let mut engine = TextEngine::new(); let mut style = LayoutStyle::default(); - style.size_expressions[expression_index] = - Some("calc(100% - 1px)".to_string()); + style.size_expressions[expression_index] = Some("calc(100% - 1px)".to_string()); let item = engine.register_replaced(800.0, 400.0, &style); let unknown = taffy::Size { width: None, @@ -3444,9 +3523,8 @@ mod tests { width: None, height: None, }; - let measure = |style: &LayoutStyle| { - ReplacedItem::from_style(512.0, 323.0, style).size(unknown) - }; + let measure = + |style: &LayoutStyle| ReplacedItem::from_style(512.0, 323.0, style).size(unknown); let max_height = LayoutStyle { max_height: Dimension::Px(128.0), @@ -3612,12 +3690,8 @@ mod tests { let loaded = &engine.loaded_families["pinned variable"]; assert_eq!(loaded.faces.len(), 2); let first_id = loaded.faces[0].font_id.unwrap(); - let selected = resolve_loaded_font( - Some("Pinned Variable"), - 700, - false, - &engine.loaded_families, - ); + let selected = + resolve_loaded_font(Some("Pinned Variable"), 700, false, &engine.loaded_families); let selected_id = selected.font_id.unwrap(); assert_ne!( first_id, selected_id, @@ -3849,11 +3923,8 @@ mod tests { wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture") } - fn isolated_fallback_engine() -> ( - TextEngine, - cosmic_text::fontdb::ID, - cosmic_text::fontdb::ID, - ) { + fn isolated_fallback_engine() -> (TextEngine, cosmic_text::fontdb::ID, cosmic_text::fontdb::ID) + { let mut engine = TextEngine::new_with_web_fonts(&[ WebFont { data: include_bytes!("../../../vendor/cosmic-text/fonts/NotoSansArabic.ttf") @@ -3920,10 +3991,7 @@ mod tests { let mut pixmap = tiny_skia::Pixmap::new(100, 80).unwrap(); engine.paint_item(item, &mut pixmap, (0.0, 0.0)); - let mut axes = std::collections::HashMap::< - [u8; 4], - std::collections::HashSet, - >::new(); + let mut axes = std::collections::HashMap::<[u8; 4], std::collections::HashSet>::new(); for key in engine.variable_swash.images.keys() { for variation in key.variations.iter() { axes.entry(*variation.tag.as_bytes()) @@ -4126,11 +4194,7 @@ mod tests { regular_style.font_weight = Some("400".to_string()); let mut black_style = regular_style.clone(); black_style.font_weight = Some("900".to_string()); - let styles = HashMap::from([ - (copy, base), - (regular, regular_style), - (black, black_style), - ]); + let styles = HashMap::from([(copy, base), (regular, regular_style), (black, black_style)]); let item = engine.try_build(&tree, copy, &styles).unwrap(); engine.measure(item, Some(200.0)); engine.finalize(item, (0.0, 0.0), 200.0, None); @@ -4207,22 +4271,12 @@ mod tests { #[test] fn optical_sizing_changes_tailwind_heading_shape_and_raster_axes() { - let (auto_geometry, auto_lines, auto_axes) = render_tailwind_optical_heading( - crate::FontOpticalSizing::Auto, - None, - 496.0, - ); - let (none_geometry, none_lines, none_axes) = render_tailwind_optical_heading( - crate::FontOpticalSizing::None, - None, - 496.0, - ); + let (auto_geometry, auto_lines, auto_axes) = + render_tailwind_optical_heading(crate::FontOpticalSizing::Auto, None, 496.0); + let (none_geometry, none_lines, none_axes) = + render_tailwind_optical_heading(crate::FontOpticalSizing::None, None, 496.0); let (explicit_geometry, explicit_lines, explicit_axes) = - render_tailwind_optical_heading( - crate::FontOpticalSizing::Auto, - Some(14.0), - 496.0, - ); + render_tailwind_optical_heading(crate::FontOpticalSizing::Auto, Some(14.0), 496.0); assert_ne!( auto_geometry, none_geometry, @@ -4234,7 +4288,10 @@ mod tests { explicit_geometry, auto_geometry, "an explicit low-level opsz coordinate must override automatic sizing" ); - assert_eq!(auto_axes, std::collections::HashSet::from([32.0f32.to_bits()])); + assert_eq!( + auto_axes, + std::collections::HashSet::from([32.0f32.to_bits()]) + ); assert!(none_axes.is_empty()); assert_eq!( explicit_axes, @@ -4244,9 +4301,7 @@ mod tests { #[test] fn text_only_inline_block_keeps_an_internal_shaping_context() { - let tree = obscura_dom::parse_html( - "ligature_name", - ); + let tree = obscura_dom::parse_html("ligature_name"); let icon = tree.get_element_by_id("icon").unwrap(); let mut style = LayoutStyle::default(); style.display = Display::Inline; @@ -4311,8 +4366,14 @@ gamma
"#, let laid = crate::dom::layout_dom(&tree, (400.0, 300.0)); let rect = |id| laid.rects[&tree.get_element_by_id(id).unwrap()]; - assert_eq!(laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space, Some(crate::WhiteSpace::Pre)); - assert_eq!(laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space, Some(crate::WhiteSpace::PreWrap)); + assert_eq!( + laid.styles[&tree.get_element_by_id("ua").unwrap()].white_space, + Some(crate::WhiteSpace::Pre) + ); + assert_eq!( + laid.styles[&tree.get_element_by_id("explicit").unwrap()].white_space, + Some(crate::WhiteSpace::PreWrap) + ); assert!((rect("ua").height - 72.0).abs() < 0.01, "{:?}", rect("ua")); assert!( (rect("explicit").height - 72.0).abs() < 0.01, @@ -4370,8 +4431,7 @@ gamma"#, assert!(lines.len() >= 2, "fixture must wrap: {lines:?}"); assert_eq!(engine.items[item].first_line_offset, 40.0); let runs: Vec<_> = engine.items[item].buffer.layout_runs().collect(); - let first_x = runs[0].glyphs.first().unwrap().x - + engine.items[item].first_line_offset; + let first_x = runs[0].glyphs.first().unwrap().x + engine.items[item].first_line_offset; let second_x = runs[1].glyphs.first().unwrap().x; assert!( (first_x - 40.0).abs() < 0.01, diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 9f9341d..5a569a6 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -14,7 +14,7 @@ use taffy::prelude::*; pub mod css; -pub use css::Stylesheet; +pub use css::{Stylesheet, StylesheetCache}; pub mod style; pub use style::compute_style; @@ -37,7 +37,10 @@ pub use dom::{ /// are ASCII case-insensitive. Keep this list aligned with the `image` crate /// features in `Cargo.toml` plus the SVG path in `paint.rs`. pub(crate) fn source_type_supported(value: &str) -> bool { - let essence = value.split_once(';').map_or(value, |(essence, _)| essence).trim(); + let essence = value + .split_once(';') + .map_or(value, |(essence, _)| essence) + .trim(); [ "image/apng", "image/bmp", @@ -94,11 +97,12 @@ mod paint; #[cfg(feature = "paint")] pub use paint::{ paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll, - paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png, - screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll, - screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace, - ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader, - ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, + paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, + prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled, + screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll, + validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics, + PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage, + MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, }; // Real inline text layout (cosmic-text) lives behind the paint feature; the @@ -122,7 +126,12 @@ pub mod inline { /// Layout-only builds have no shaper, so no container is ever treated /// as a cosmic-text inline formatting context: the word-split path /// handles text geometry for `getBoundingClientRect`. - pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap) -> Option { + pub fn try_build( + &mut self, + _tree: &DomTree, + _id: NodeId, + _styles: &HashMap, + ) -> Option { None } /// See `try_build`: inline runs likewise fall back to word-split @@ -224,16 +233,31 @@ impl Affine2 { } pub fn translate(x: f32, y: f32) -> Self { - Self { e: x, f: y, ..Self::IDENTITY } + Self { + e: x, + f: y, + ..Self::IDENTITY + } } pub fn scale(x: f32, y: f32) -> Self { - Self { a: x, d: y, ..Self::IDENTITY } + Self { + a: x, + d: y, + ..Self::IDENTITY + } } pub fn rotate(degrees: f32) -> Self { let (sin, cos) = degrees.to_radians().sin_cos(); - Self { a: cos, b: sin, c: -sin, d: cos, e: 0.0, f: 0.0 } + Self { + a: cos, + b: sin, + c: -sin, + d: cos, + e: 0.0, + f: 0.0, + } } pub fn skew(x_degrees: f32, y_degrees: f32) -> Self { @@ -254,7 +278,10 @@ impl Affine2 { } pub fn map_point(self, x: f32, y: f32) -> (f32, f32) { - (self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f) + ( + self.a * x + self.c * y + self.e, + self.b * x + self.d * y + self.f, + ) } pub fn map_rect(self, rect: Rect) -> Rect { @@ -264,10 +291,22 @@ impl Affine2 { self.map_point(rect.x, rect.y + rect.height), self.map_point(rect.x + rect.width, rect.y + rect.height), ]; - let left = points.iter().map(|point| point.0).fold(f32::INFINITY, f32::min); - let top = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min); - let right = points.iter().map(|point| point.0).fold(f32::NEG_INFINITY, f32::max); - let bottom = points.iter().map(|point| point.1).fold(f32::NEG_INFINITY, f32::max); + let left = points + .iter() + .map(|point| point.0) + .fold(f32::INFINITY, f32::min); + let top = points + .iter() + .map(|point| point.1) + .fold(f32::INFINITY, f32::min); + let right = points + .iter() + .map(|point| point.0) + .fold(f32::NEG_INFINITY, f32::max); + let bottom = points + .iter() + .map(|point| point.1) + .fold(f32::NEG_INFINITY, f32::max); Rect { x: left, y: top, @@ -317,7 +356,10 @@ pub struct TransformLength { impl TransformLength { pub fn px(value: f32) -> Self { - Self { value: Dimension::Px(value), expression: None } + Self { + value: Dimension::Px(value), + expression: None, + } } } @@ -343,7 +385,12 @@ impl Rect { let x1 = (self.x + self.width).min(other.x + other.width); let y1 = (self.y + self.height).min(other.y + other.height); if x1 > x0 && y1 > y0 { - Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }) + Some(Rect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + }) } else { None } @@ -356,7 +403,12 @@ impl Rect { let y0 = self.y.min(other.y); let x1 = (self.x + self.width).max(other.x + other.width); let y1 = (self.y + self.height).max(other.y + other.height); - Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } + Rect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + } } } @@ -380,11 +432,7 @@ pub fn quantize_scroll_value(value: f32, device_scale_factor: f32) -> f32 { (value * scale).round() / scale } -pub(crate) fn quantized_scroll_range( - content: f32, - client: f32, - device_scale_factor: f32, -) -> f32 { +pub(crate) fn quantized_scroll_range(content: f32, client: f32, device_scale_factor: f32) -> f32 { (quantize_scroll_value(content, device_scale_factor) - quantize_scroll_value(client, device_scale_factor)) .max(0.0) @@ -532,8 +580,7 @@ impl BackgroundPositionAxis { pub(crate) fn interpolate(self, other: Self, position: f32) -> Self { Self { length: self.length + (other.length - self.length) * position, - percentage: self.percentage - + (other.percentage - self.percentage) * position, + percentage: self.percentage + (other.percentage - self.percentage) * position, } } } @@ -816,8 +863,7 @@ pub struct LayoutStyle { /// First `radial-gradient(...)` layer: center in box-relative fractions /// and color stops. It is painted below the first linear layer, matching /// the common `linear-gradient(...), radial-gradient(...)` hero pattern. - pub background_radial_gradient: - Option<((f32, f32), Vec<([u8; 4], Option)>)>, + pub background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option)>)>, /// `conic-gradient(...)` background. The angle is the CSS `from` angle, /// the center is a fraction of the border box, and stops are normalized /// during paint. Conic gradients commonly provide the color source for a @@ -1536,7 +1582,11 @@ pub struct LayoutNode { impl LayoutNode { pub fn leaf(style: LayoutStyle) -> Self { - LayoutNode { style, text: None, children: Vec::new() } + LayoutNode { + style, + text: None, + children: Vec::new(), + } } } @@ -1568,9 +1618,9 @@ fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId { if node.children.is_empty() { tree.new_leaf(style).expect("taffy new_leaf") } else { - let child_ids: Vec = - node.children.iter().map(|c| build_node(tree, c)).collect(); - tree.new_with_children(style, &child_ids).expect("taffy new_with_children") + let child_ids: Vec = node.children.iter().map(|c| build_node(tree, c)).collect(); + tree.new_with_children(style, &child_ids) + .expect("taffy new_with_children") } } @@ -1609,7 +1659,10 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { // `text_align` is separate from real flex/grid `align-items`, so a // text-align declaration never changes how flex children are sized. let promote_for_alignment = style.display == Display::Block - && matches!(style.text_align, Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END)); + && matches!( + style.text_align, + Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END) + ); s.display = match style.display { Display::Block if promote_for_alignment => taffy::style::Display::Flex, @@ -1816,7 +1869,9 @@ fn dimension(v: Dimension) -> taffy::style::Dimension { // rather than panicking. Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0), Dimension::Ex(v) => taffy::style::Dimension::length(v * 16.0 * 0.528_320_3), - Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => taffy::style::Dimension::length(v), + Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => { + taffy::style::Dimension::length(v) + } } } @@ -1866,7 +1921,12 @@ mod tests { use super::*; fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle { - LayoutStyle { display, width: Dimension::Px(w), height: Dimension::Px(h), ..Default::default() } + LayoutStyle { + display, + width: Dimension::Px(w), + height: Dimension::Px(h), + ..Default::default() + } } #[test] @@ -1909,7 +1969,10 @@ mod tests { display: Display::Block, width: Dimension::Px(200.0), height: Dimension::Px(40.0), - margin: Edges { right: 50.0, ..Default::default() }, + margin: Edges { + right: 50.0, + ..Default::default() + }, margin_auto: [false, false, false, true], ..Default::default() }; @@ -1957,7 +2020,12 @@ mod tests { #[test] fn flex_row_lays_out_horizontally() { let root = LayoutNode { - style: LayoutStyle { display: Display::Flex, width: Dimension::Px(600.0), height: Dimension::Px(100.0), ..Default::default() }, + style: LayoutStyle { + display: Display::Flex, + width: Dimension::Px(600.0), + height: Dimension::Px(100.0), + ..Default::default() + }, text: None, children: vec![ LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)), @@ -1983,7 +2051,12 @@ mod tests { display: Display::Block, width: Dimension::Px(100.0), height: Dimension::Px(100.0), - padding: Edges { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 }, + padding: Edges { + top: 10.0, + right: 10.0, + bottom: 10.0, + left: 10.0, + }, ..Default::default() }, text: None, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 8f601f9..befb303 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -5,9 +5,9 @@ //! are later enhancements. Pure Rust (tiny-skia, CPU), deterministic, no system //! dependencies, so a screenshot is reproducible across hosts. -use obscura_dom::tree::DomTree; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; -use std::collections::{HashMap, VecDeque}; +use obscura_dom::tree::DomTree; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use tiny_skia::{ Color, FillRule, FilterQuality, GradientStop, LinearGradient, Paint, PathBuilder, Pattern, @@ -22,10 +22,14 @@ static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.t static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); -use crate::dom::layout_dom_with_web_fonts; +use crate::dom::layout_dom_with_web_fonts_and_stylesheet_cache; const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512; const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024; +/// CSS `content:url(...)` is only discoverable after cascade. Remember a +/// bounded set of successful selections so repeated prepares can seed their +/// intrinsic geometry before that cascade instead of always laying out twice. +const DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES: usize = 256; const MISSING_RESOURCE_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2); /// Exact formats the renderer can decode. Do not use `image/*` or `*/*` here: /// either wildcard permits a content-negotiating server to choose AVIF, @@ -74,6 +78,12 @@ enum CachedResource { Missing(std::time::Instant), } +#[derive(Clone, Debug, PartialEq)] +struct RememberedContentImageIntrinsic { + resolved_url: String, + dimensions: (f32, f32), +} + /// Page-scoped raw resource bytes shared by layout preparation and repeated /// paints. Entries are FIFO-bounded by both count and retained byte size. /// Successful bytes use `Arc` so consumers never clone an image/font body. @@ -83,6 +93,11 @@ pub struct RenderResourceCache { retained_bytes: usize, max_entries: usize, max_bytes: usize, + content_image_intrinsics: HashMap, + content_image_intrinsic_order: VecDeque, + max_content_image_intrinsics: usize, + #[cfg(test)] + content_image_layout_retries: usize, loader: Box, } @@ -116,6 +131,11 @@ impl RenderResourceCache { retained_bytes: 0, max_entries, max_bytes, + content_image_intrinsics: HashMap::new(), + content_image_intrinsic_order: VecDeque::new(), + max_content_image_intrinsics: max_entries.min(DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES), + #[cfg(test)] + content_image_layout_retries: 0, loader: Box::new(loader), } } @@ -184,11 +204,7 @@ impl RenderResourceCache { ) -> Option> { let resolved_url = resolve_resource_url(src, base_url)?; if resolved_url.starts_with("data:") { - let mut scratch = RenderResourceCache::with_loader_and_limits( - |_url: &str| None, - 0, - 0, - ); + let mut scratch = RenderResourceCache::with_loader_and_limits(|_url: &str| None, 0, 0); return Some( fetch_bytes(&resolved_url, None, &mut scratch) .and_then(|bytes| image_metadata_from_bytes(&bytes)) @@ -200,9 +216,7 @@ impl RenderResourceCache { image_metadata_from_bytes(bytes) .map(|(width, height)| (resolved_url, width, height)), ), - Some(CachedResource::Missing(at)) - if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => - { + Some(CachedResource::Missing(at)) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => { Some(None) } _ => None, @@ -256,9 +270,7 @@ impl RenderResourceCache { if let Some(entry) = self.entries.get(url) { match entry { CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)), - CachedResource::Missing(at) - if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => - { + CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => { return None; } CachedResource::Missing(_) => {} @@ -323,6 +335,79 @@ impl RenderResourceCache { self.retained_bytes = self.retained_bytes.saturating_sub(bytes.len()); } } + + /// Seed a prior stable `content:url(...)` selection into the ordinary + /// intrinsic and selected-image maps before cascade. Node ids are scoped + /// to this page cache; removed/non-image ids are pruned eagerly. + fn seed_content_image_intrinsics( + &mut self, + tree: &DomTree, + intrinsic: &mut HashMap, + selected: &mut HashMap, + ) -> HashSet { + let remembered = self + .content_image_intrinsics + .iter() + .map(|(&nid, value)| (nid, value.clone())) + .collect::>(); + let mut seeded = HashSet::with_capacity(remembered.len()); + for (nid, value) in remembered { + let is_live_image = tree.get_node(nid).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "img") + }); + if !is_live_image { + self.forget_content_image_intrinsic(nid); + continue; + } + intrinsic.insert(nid, value.dimensions); + selected.insert( + nid, + SelectedImage { + resolved_url: value.resolved_url, + density: 1.0, + }, + ); + seeded.insert(nid); + } + seeded + } + + fn remember_content_image_intrinsic( + &mut self, + nid: obscura_dom::tree::NodeId, + resolved_url: String, + dimensions: (f32, f32), + ) { + if self.max_content_image_intrinsics == 0 { + return; + } + let replacing = self.content_image_intrinsics.contains_key(&nid); + self.content_image_intrinsic_order + .retain(|remembered| *remembered != nid); + while !replacing && self.content_image_intrinsics.len() >= self.max_content_image_intrinsics + { + let Some(oldest) = self.content_image_intrinsic_order.pop_front() else { + break; + }; + self.content_image_intrinsics.remove(&oldest); + } + self.content_image_intrinsic_order.push_back(nid); + self.content_image_intrinsics.insert( + nid, + RememberedContentImageIntrinsic { + resolved_url, + dimensions, + }, + ); + } + + fn forget_content_image_intrinsic(&mut self, nid: obscura_dom::tree::NodeId) { + if self.content_image_intrinsics.remove(&nid).is_some() { + self.content_image_intrinsic_order + .retain(|remembered| *remembered != nid); + } + } } /// The exact responsive image candidate chosen during preparation. @@ -366,6 +451,7 @@ pub struct CaptureRegion { pub width: f32, pub height: f32, pub scale: f32, + output_size: Option<(u32, u32)>, } impl CaptureRegion { @@ -376,6 +462,29 @@ impl CaptureRegion { width, height, scale, + output_size: None, + } + } + + /// Construct a region whose transport protocol has already resolved the + /// exact output pixel size. The CSS-space paint extent remains independent + /// of that integer output surface. + pub fn with_output_size( + x: f32, + y: f32, + width: f32, + height: f32, + scale: f32, + output_width: u32, + output_height: u32, + ) -> Self { + Self { + x, + y, + width, + height, + scale, + output_size: Some((output_width, output_height)), } } } @@ -383,7 +492,9 @@ impl CaptureRegion { /// Hard surface limits for one capture. The byte cap bounds both the native /// CSS-pixel raster and the scaled output before either allocation occurs. pub const MAX_CAPTURE_DIMENSION: u32 = 32_768; -pub const MAX_CAPTURE_PIXELS: u64 = 64 * 1024 * 1024; +pub const MAX_CAPTURE_PIXELS: u64 = 16 * 1024 * 1024; +pub const MAX_CAPTURE_SCALE: f32 = 16.0; +pub const MAX_CAPTURE_PEAK_BYTES: u64 = 128 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CaptureError { @@ -393,9 +504,7 @@ pub enum CaptureError { EncodeFailed, } -fn checked_capture_dimensions( - region: CaptureRegion, -) -> Result<(u32, u32, u32, u32), CaptureError> { +fn checked_capture_dimensions(region: CaptureRegion) -> Result<(u32, u32, u32, u32), CaptureError> { if !region.x.is_finite() || !region.y.is_finite() || !region.width.is_finite() @@ -407,18 +516,30 @@ fn checked_capture_dimensions( { return Err(CaptureError::InvalidRegion); } + if region.scale > MAX_CAPTURE_SCALE { + return Err(CaptureError::AllocationLimitExceeded); + } let native_width = region.width.ceil(); let native_height = region.height.ceil(); - let output_width = (region.width * region.scale).ceil(); - let output_height = (region.height * region.scale).ceil(); + let (output_width, output_height) = match region.output_size { + Some((width, height)) => (f64::from(width), f64::from(height)), + None => ( + f64::from(region.width) * f64::from(region.scale), + f64::from(region.height) * f64::from(region.scale), + ), + }; + let output_width = output_width.ceil(); + let output_height = output_height.ceil(); if !native_width.is_finite() || !native_height.is_finite() || !output_width.is_finite() || !output_height.is_finite() + || output_width <= 0.0 + || output_height <= 0.0 || native_width > MAX_CAPTURE_DIMENSION as f32 || native_height > MAX_CAPTURE_DIMENSION as f32 - || output_width > MAX_CAPTURE_DIMENSION as f32 - || output_height > MAX_CAPTURE_DIMENSION as f32 + || output_width > f64::from(MAX_CAPTURE_DIMENSION) + || output_height > f64::from(MAX_CAPTURE_DIMENSION) { return Err(CaptureError::AllocationLimitExceeded); } @@ -428,17 +549,33 @@ fn checked_capture_dimensions( output_width as u32, output_height as u32, ); - for (width, height) in [ - (dimensions.0, dimensions.1), - (dimensions.2, dimensions.3), - ] { + for (width, height) in [(dimensions.0, dimensions.1), (dimensions.2, dimensions.3)] { if u64::from(width).saturating_mul(u64::from(height)) > MAX_CAPTURE_PIXELS { return Err(CaptureError::AllocationLimitExceeded); } } + let native_pixels = u64::from(dimensions.0).saturating_mul(u64::from(dimensions.1)); + let output_pixels = u64::from(dimensions.2).saturating_mul(u64::from(dimensions.3)); + let peak_pixels = if dimensions.0 == dimensions.2 && dimensions.1 == dimensions.3 { + native_pixels + } else { + // Scaling owns the native RGBA surface and output RGBA surface at the + // same time. Bound their combined live bytes before either allocation. + native_pixels.saturating_add(output_pixels) + }; + if peak_pixels.saturating_mul(4) > MAX_CAPTURE_PEAK_BYTES { + return Err(CaptureError::AllocationLimitExceeded); + } Ok(dimensions) } +/// Validate every surface allocation implied by a capture before painting. +/// Protocol adapters use this on legacy viewport captures which preserve the +/// renderer's native PNG bytes but must obey the same limits as region capture. +pub fn validate_capture_region(region: CaptureRegion) -> Result<(), CaptureError> { + checked_capture_dimensions(region).map(|_| ()) +} + impl ResolvedScrollState { pub fn root_offset(&self) -> (f32, f32) { self.root_offset @@ -455,7 +592,7 @@ impl ResolvedScrollState { &self, id: obscura_dom::tree::NodeId, ) -> Option { - self.inherited_clips.get(id.index()).copied().flatten() + self.inherited_clips.get(id.index()).cloned().flatten() } } @@ -491,9 +628,7 @@ impl PreparedRender { self.content_size } - pub fn viewport_fixed_nodes( - &self, - ) -> &std::collections::HashSet { + pub fn viewport_fixed_nodes(&self) -> &std::collections::HashSet { &self.viewport_fixed } @@ -504,10 +639,8 @@ impl PreparedRender { pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) { let clamp_axis = |requested: f32, content: f32, viewport: f32| { if requested.is_finite() { - crate::quantize_scroll_value(requested, 1.0).clamp( - 0.0, - crate::quantized_scroll_range(content, viewport, 1.0), - ) + crate::quantize_scroll_value(requested, 1.0) + .clamp(0.0, crate::quantized_scroll_range(content, viewport, 1.0)) } else { 0.0 } @@ -598,7 +731,7 @@ impl PreparedRender { out: &mut [Option], ) { if let Some(slot) = out.get_mut(id.index()) { - *slot = inherited; + *slot = inherited.clone(); } let next = match (laid.styles.get(&id), laid.rects.get(&id)) { (Some(style), Some(rect)) @@ -620,7 +753,7 @@ impl PreparedRender { _ => inherited, }; for child in crate::dom::rendered_children(tree, id) { - resolve_clips(tree, laid, movement, child, next, out); + resolve_clips(tree, laid, movement, child, next.clone(), out); } } if let Some(root_node) = tree @@ -687,10 +820,7 @@ impl PreparedRender { /// Axis-aligned bounds of the transformed border box in immutable document /// space, excluding root-scroll and sticky movement. - pub fn document_rect( - &self, - id: obscura_dom::tree::NodeId, - ) -> Option { + pub fn document_rect(&self, id: obscura_dom::tree::NodeId) -> Option { let rect = *self.layout.rects.get(&id)?; Some( self.layout @@ -707,10 +837,7 @@ impl PreparedRender { /// borders but retain padding. This deliberately ignores visual /// transforms: client metrics describe layout geometry, unlike /// `getBoundingClientRect()`. - pub fn client_size( - &self, - id: obscura_dom::tree::NodeId, - ) -> Option<(f32, f32)> { + pub fn client_size(&self, id: obscura_dom::tree::NodeId) -> Option<(f32, f32)> { let rect = self.layout.rects.get(&id)?; let style = self.layout.styles.get(&id)?; if style.ignores_used_box_sizes() { @@ -778,7 +905,9 @@ impl PreparedRender { ); out.insert( "z-index", - style.z_index.map_or_else(|| "auto".to_string(), |v| v.to_string()), + style + .z_index + .map_or_else(|| "auto".to_string(), |v| v.to_string()), ); out.insert( "visibility", @@ -861,7 +990,9 @@ impl PreparedRender { ); out.insert( "-webkit-line-clamp", - style.webkit_line_clamp.map_or_else(|| "none".to_string(), |lines| lines.to_string()), + style + .webkit_line_clamp + .map_or_else(|| "none".to_string(), |lines| lines.to_string()), ); out.insert( "-webkit-box-orient", @@ -973,7 +1104,14 @@ impl PreparedRender { ("margin-bottom", style.margin.bottom, style.margin_auto[2]), ("margin-left", style.margin.left, style.margin_auto[3]), ] { - out.insert(name, if auto { "auto".to_string() } else { css_px(value) }); + out.insert( + name, + if auto { + "auto".to_string() + } else { + css_px(value) + }, + ); } for (name, value) in [ ("padding-top", style.padding.top), @@ -1010,9 +1148,18 @@ impl PreparedRender { } for (name, radius) in [ ("border-top-left-radius", style.border_model.radii.top_left), - ("border-top-right-radius", style.border_model.radii.top_right), - ("border-bottom-right-radius", style.border_model.radii.bottom_right), - ("border-bottom-left-radius", style.border_model.radii.bottom_left), + ( + "border-top-right-radius", + style.border_model.radii.top_right, + ), + ( + "border-bottom-right-radius", + style.border_model.radii.bottom_right, + ), + ( + "border-bottom-left-radius", + style.border_model.radii.bottom_left, + ), ] { out.insert(name, corner_radius_css(radius)); } @@ -1067,8 +1214,16 @@ impl PreparedRender { .align_content .map_or_else(|| "normal".to_string(), align_content_css), ); - out.insert("column-gap", style.column_gap.map_or_else(|| "normal".to_string(), css_px)); - out.insert("row-gap", style.row_gap.map_or_else(|| "normal".to_string(), css_px)); + out.insert( + "column-gap", + style + .column_gap + .map_or_else(|| "normal".to_string(), css_px), + ); + out.insert( + "row-gap", + style.row_gap.map_or_else(|| "normal".to_string(), css_px), + ); out.insert( "grid-auto-flow", match style.grid_auto_flow.unwrap_or(taffy::GridAutoFlow::Row) { @@ -1084,8 +1239,7 @@ impl PreparedRender { "transform", transform_css(style, rect, self.root_font_size, self.viewport), ); - out - .insert("transform-origin", transform_origin_css(style, rect)); + out.insert("transform-origin", transform_origin_css(style, rect)); out.insert( "translate", style.individual_translate.map_or_else( @@ -1231,10 +1385,7 @@ impl PreparedRender { ) } - pub fn selected_image( - &self, - id: obscura_dom::tree::NodeId, - ) -> Option<&SelectedImage> { + pub fn selected_image(&self, id: obscura_dom::tree::NodeId) -> Option<&SelectedImage> { self.selected_images.get(&id) } } @@ -1281,17 +1432,18 @@ fn radius_value_css(value: crate::RadiusValue) -> String { fn corner_radius_css(radius: crate::CornerRadius) -> String { let x = radius_value_css(radius.x); let y = radius_value_css(radius.y); - if x == y { x } else { format!("{x} {y}") } + if x == y { + x + } else { + format!("{x} {y}") + } } fn css_color([r, g, b, a]: [u8; 4]) -> String { if a == 255 { format!("rgb({r}, {g}, {b})") } else { - format!( - "rgba({r}, {g}, {b}, {})", - css_number(a as f32 / 255.0) - ) + format!("rgba({r}, {g}, {b}, {})", css_number(a as f32 / 255.0)) } } @@ -1341,7 +1493,12 @@ fn transform_css( if style.transform_ops.is_empty() { return "none".to_string(); } - let fallback = crate::Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; + let fallback = crate::Rect { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + }; let matrix = crate::dom::resolved_transform_property_matrix( style, rect.unwrap_or(&fallback), @@ -1350,15 +1507,20 @@ fn transform_css( ); format!( "matrix({}, {}, {}, {}, {}, {})", - css_number(matrix.a), css_number(matrix.b), css_number(matrix.c), - css_number(matrix.d), css_number(matrix.e), css_number(matrix.f), + css_number(matrix.a), + css_number(matrix.b), + css_number(matrix.c), + css_number(matrix.d), + css_number(matrix.e), + css_number(matrix.f), ) } fn transform_origin_css(style: &crate::LayoutStyle, rect: Option<&crate::Rect>) -> String { - let (x, y) = style - .transform_origin - .unwrap_or((crate::Dimension::Percent(0.5), crate::Dimension::Percent(0.5))); + let (x, y) = style.transform_origin.unwrap_or(( + crate::Dimension::Percent(0.5), + crate::Dimension::Percent(0.5), + )); let resolve = |value: crate::Dimension, axis: f32| match value { crate::Dimension::Percent(fraction) => css_px(fraction * axis), other => dimension_css(other, "0px"), @@ -1412,10 +1574,29 @@ pub fn prepare_dom_with_dynamic_fonts( resources: &mut RenderResourceCache, dynamic_fonts: &[DynamicFontFace], ) -> Option { - if !viewport.0.is_finite() - || !viewport.1.is_finite() - || viewport.0 <= 0.0 - || viewport.1 <= 0.0 + let mut stylesheet_cache = crate::css::StylesheetCache::default(); + prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + &mut stylesheet_cache, + ) +} + +/// Prepare a DOM while retaining source parsing and selector indexing across +/// relayouts of the same document. Cascade, computed styles, and layout are +/// always rebuilt from the live DOM. +pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, +) -> Option { + if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0 { return None; } @@ -1425,6 +1606,26 @@ pub fn prepare_dom_with_dynamic_fonts( // each URL is still fetched at most once. let (mut intrinsic, mut selected_images) = collect_image_intrinsics(tree, viewport, base_url, resources); + // Preserve the HTML source fallback separately: a remembered CSS content + // image temporarily overrides it, but a changed/removed/failed content + // selection must restore the source before the correction layout. + // Only remembered nodes can need their HTML fallback restored. Avoid + // cloning the maps for every ordinary image on image-heavy pages. + let remembered_content_nodes = resources + .content_image_intrinsics + .keys() + .copied() + .collect::>(); + let source_intrinsic = remembered_content_nodes + .iter() + .filter_map(|nid| intrinsic.get(nid).copied().map(|value| (*nid, value))) + .collect::>(); + let source_selected_images = remembered_content_nodes + .iter() + .filter_map(|nid| selected_images.get(nid).cloned().map(|value| (*nid, value))) + .collect::>(); + let seeded_content_images = + resources.seed_content_image_intrinsics(tree, &mut intrinsic, &mut selected_images); let fonts = collect_web_fonts(tree, base_url, resources, dynamic_fonts); // Most framework pages use web fonts and many decorative SVG icons, but // only SVG text needs the page font faces. Avoid cloning/loading the page @@ -1434,7 +1635,13 @@ pub fn prepare_dom_with_dynamic_fonts( } else { svg_font_database() }; - let mut laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); + let mut laid = layout_dom_with_web_fonts_and_stylesheet_cache( + tree, + viewport, + &intrinsic, + &fonts, + stylesheet_cache, + ); // `content:url(...)` is computed by the author cascade, whereas ordinary // HTML image sources are available before layout. Pay for a second layout // only on the uncommon pages that actually use a CSS image as replaced @@ -1446,8 +1653,21 @@ pub fn prepare_dom_with_dynamic_fonts( resources, &mut intrinsic, &mut selected_images, + &source_intrinsic, + &source_selected_images, + &seeded_content_images, ) { - laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); + #[cfg(test)] + { + resources.content_image_layout_retries += 1; + } + laid = layout_dom_with_web_fonts_and_stylesheet_cache( + tree, + viewport, + &intrinsic, + &fonts, + stylesheet_cache, + ); } let content_size = laid.scrolling_content_size(tree, viewport); let viewport_fixed = laid.viewport_fixed_nodes(tree); @@ -1482,6 +1702,14 @@ pub fn paint_prepared( resources: &mut RenderResourceCache, scroll: (f32, f32), ) -> Option { + validate_capture_region(CaptureRegion::new( + scroll.0, + scroll.1, + prepared.viewport.0, + prepared.viewport.1, + 1.0, + )) + .ok()?; let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; pixmap.fill(Color::WHITE); @@ -1506,6 +1734,7 @@ pub fn paint_prepared( None, None, (0.0, 0.0), + 1.0, ) } @@ -1517,6 +1746,14 @@ pub fn paint_prepared_with_scroll( resources: &mut RenderResourceCache, scroll: &ResolvedScrollState, ) -> Option { + validate_capture_region(CaptureRegion::new( + scroll.root_offset().0, + scroll.root_offset().1, + prepared.viewport.0, + prepared.viewport.1, + 1.0, + )) + .ok()?; let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; pixmap.fill(Color::WHITE); @@ -1541,6 +1778,7 @@ pub fn paint_prepared_with_scroll( None, None, (0.0, 0.0), + 1.0, ) } @@ -1557,8 +1795,20 @@ pub fn paint_prepared_region_with_scroll( ) -> Result { let (native_width, native_height, output_width, output_height) = checked_capture_dimensions(region)?; - let mut pixmap = Pixmap::new(native_width, native_height) - .ok_or(CaptureError::AllocationLimitExceeded)?; + let scale_matches_output = + (output_width as f64 - f64::from(region.width) * f64::from(region.scale)).abs() <= 1.0 + && (output_height as f64 - f64::from(region.height) * f64::from(region.scale)).abs() + <= 1.0; + let native_scaled = (output_width != native_width || output_height != native_height) + && scale_matches_output + && native_raster_scale_supported(tree, &prepared.layout); + let (paint_width, paint_height, raster_scale) = if native_scaled { + (output_width, output_height, region.scale) + } else { + (native_width, native_height, 1.0) + }; + let mut pixmap = + Pixmap::new(paint_width, paint_height).ok_or(CaptureError::AllocationLimitExceeded)?; pixmap.fill(Color::WHITE); // Resolved node movement already contains `-root_scroll` for ordinary @@ -1589,10 +1839,11 @@ pub fn paint_prepared_region_with_scroll( None, Some((region.width, region.height)), surface_offset, + raster_scale, ) .ok_or(CaptureError::PaintFailed)?; - if output_width == native_width && output_height == native_height { + if native_scaled || (output_width == native_width && output_height == native_height) { return Ok(pixmap); } @@ -1600,7 +1851,7 @@ pub fn paint_prepared_region_with_scroll( // CSS-pixel space. Keep scale out of layout and scroll state, then use one // premultiplied-alpha high-quality surface transform. Vector primitives // still take the direct raster path whenever scale is 1. - let source = image::RgbaImage::from_raw(native_width, native_height, pixmap.data().to_vec()) + let source = image::RgbaImage::from_raw(native_width, native_height, pixmap.take()) .ok_or(CaptureError::PaintFailed)?; let scaled = image::imageops::resize( &source, @@ -1613,6 +1864,48 @@ pub fn paint_prepared_region_with_scroll( Pixmap::from_vec(scaled.into_raw(), size).ok_or(CaptureError::PaintFailed) } +/// Whether a retained display list can currently be rasterized directly at a +/// non-1 device scale. The native path deliberately starts with the ubiquitous +/// solid-box/text subset. Effects whose implementation creates logical-pixel +/// intermediate surfaces retain the proven CSS-raster + resample path until +/// those layers can carry an explicit source/device transform. +fn native_raster_scale_supported(tree: &DomTree, laid: &crate::DomLayout) -> bool { + fn style_supported(style: &crate::LayoutStyle) -> bool { + let simple = !has_authored_transform(style) + && style.opacity.is_none_or(|opacity| opacity >= 1.0) + && style.clip_path.is_none() + && style.background_image.is_none() + && style.mask_image.is_none() + && style.background_gradient.is_none() + && style.background_radial_gradient.is_none() + && style.background_conic_gradient.is_none() + && style.background_gradient_layers.is_empty() + && !style.background_clip_text + && style.box_shadow.is_none() + && style.content_image.is_none() + && style.text_overflow == crate::TextOverflow::Clip; + simple + && style.before_pseudo.as_deref().is_none_or(style_supported) + && style.after_pseudo.as_deref().is_none_or(style_supported) + } + + if laid.clip_rects.values().any(|clip| clip.is_some()) + || laid.styles.values().any(|style| !style_supported(style)) + { + return false; + } + !tree.descendants(tree.document()).into_iter().any(|id| { + tree.get_node(id).is_some_and(|node| { + node.as_element().is_some_and(|name| { + matches!( + name.local.as_ref(), + "img" | "picture" | "svg" | "canvas" | "video" + ) + }) + }) + }) +} + /// The used `z-index` for an element which participates in stacking order. /// /// A non-auto z-index applies not only to positioned boxes, but also to @@ -1732,6 +2025,7 @@ fn paint_laid_dom_scrolled( clip_scope_root: Option, surface_extent: Option<(f32, f32)>, surface_offset: (f32, f32), + raster_scale: f32, ) -> Option { let scroll_state = match resolved_scroll { Some(resolved) => ScrollPaintState::from_resolved( @@ -1766,18 +2060,19 @@ fn paint_laid_dom_scrolled( // their painting is owned by that raster, so they are skipped in both the // box/text loop below and the inline-formatting loop after it (an svg // `` element must not also paint its glyphs on top of the raster). - let mut svg_subtree_skip: std::collections::HashSet = std::collections::HashSet::new(); + let mut svg_subtree_skip: std::collections::HashSet = + std::collections::HashSet::new(); // Text is painted in a second pass, after the box-order loop. Remember // every subtree already painted into an opacity layer so its shaped text // is not drawn a second time at full opacity in the outer pass. - let mut opacity_subtree_skip: std::collections::HashSet< - obscura_dom::tree::NodeId, - > = std::collections::HashSet::new(); + let mut opacity_subtree_skip: std::collections::HashSet = + std::collections::HashSet::new(); // External sprite symbols, keyed by "url#id", extracted from a fetched // sprite file so a `` resolves. One sprite backs many // icons (a whole logo/icon band), so cache the parsed symbol across every // inline svg on the page rather than re-parsing the sprite per icon. - let mut sprite_cache: std::collections::HashMap> = std::collections::HashMap::new(); + let mut sprite_cache: std::collections::HashMap> = + std::collections::HashMap::new(); // Whether any element carries a `transform: translate()`. When none does // (the overwhelmingly common case), every node's accumulated offset is // zero, so skip the per-node ancestor walk entirely and keep the paint @@ -1794,7 +2089,8 @@ fn paint_laid_dom_scrolled( let mut neg_layers: Vec<(i32, Vec)> = Vec::new(); let mut pos_layers: Vec<(i32, Vec)> = Vec::new(); let mut normal: Vec = Vec::new(); - let mut consumed: std::collections::HashSet = std::collections::HashSet::new(); + let mut consumed: std::collections::HashSet = + std::collections::HashSet::new(); let mut paint_nodes = paint_root.into_iter().collect::>(); paint_nodes.extend(tree.descendants(paint_root.unwrap_or_else(|| tree.document()))); for nid in paint_nodes.iter().copied() { @@ -1907,9 +2203,7 @@ fn paint_laid_dom_scrolled( if svg_subtree_skip.contains(&nid) { continue; } - if suppress_stacking_for != Some(nid) - && stacking_z_index(tree, laid, nid).is_some() - { + if suppress_stacking_for != Some(nid) && stacking_z_index(tree, laid, nid).is_some() { opacity_subtree_skip.insert(nid); opacity_subtree_skip.extend(tree.descendants(nid)); // A stacking context is one structural paint item in its parent. @@ -1939,6 +2233,7 @@ fn paint_laid_dom_scrolled( clip_scope_root, surface_extent, surface_offset, + raster_scale, )?; continue; } @@ -1950,13 +2245,30 @@ fn paint_laid_dom_scrolled( if node.is_text() { if let Some(items) = laid.word_ifc_items.get(&nid) { let offset = scroll_state.translation_for(laid, nid); - let clip = scroll_state.shaped_text_clip_for(laid, nid); + let overflow_clip = scroll_state.shaped_text_overflow_clip_for(laid, nid); + let clip = overflow_clip.as_ref().map(|clip| { + clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport)) + }); + let clip_mask = overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); for &item in items { - laid.text_engine - .paint_item_with_clip(item, &mut pixmap, offset, clip); + laid.text_engine.paint_item_with_clip_mask_scaled( + item, + &mut pixmap, + offset, + clip, + clip_mask.as_ref(), + raster_scale, + ); } } else { - paint_text_node(tree, nid, laid, &scroll_state, &mut pixmap); + paint_text_node(tree, nid, laid, &scroll_state, &mut pixmap, raster_scale); } for generated in &generated_after_at[paint_index] { paint_in_flow_generated_box( @@ -1968,6 +2280,7 @@ fn paint_laid_dom_scrolled( root_font_size, base_url, image_cache, + raster_scale, ); } continue; @@ -1990,12 +2303,8 @@ fn paint_laid_dom_scrolled( if suppress_transform_for != Some(nid) && has_authored_transform(style) { opacity_subtree_skip.insert(nid); opacity_subtree_skip.extend(tree.descendants(nid)); - let transform = crate::dom::resolved_transform_matrix( - style, - &rect, - root_font_size, - viewport, - ); + let transform = + crate::dom::resolved_transform_matrix(style, &rect, root_font_size, viewport); if transform.is_translation() { // Translation-only transforms retain the direct offset path: // no surface allocation and no resampling for carousels and @@ -2021,6 +2330,7 @@ fn paint_laid_dom_scrolled( clip_scope_root, surface_extent, surface_offset, + raster_scale, )?; continue; } @@ -2030,7 +2340,10 @@ fn paint_laid_dom_scrolled( // established inside this subtree, then map the finished surface. // This makes text, borders, shadows, SVG, images, and pseudos share // one transform exactly like Gecko's nsDisplayTransform wrapper. - let outside_clip = scroll_state.clip_for(laid, nid); + let outside_overflow_clip = scroll_state.overflow_clip_for(laid, nid); + let outside_clip = outside_overflow_clip + .as_ref() + .map(|clip| clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport))); let movement = scroll_state.translation_for(laid, nid); let display_transform = crate::Affine2::translate(movement.0, movement.1) .then(transform) @@ -2053,13 +2366,10 @@ fn paint_laid_dom_scrolled( continue; }; let needed_source = inverse.map_rect(target); - let Some(source_bounds) = transform_subtree_source_bounds( - tree, - laid, - &scroll_state, - nid, - ) - .and_then(|bounds| bounds.intersect(&needed_source)) else { + let Some(source_bounds) = + transform_subtree_source_bounds(tree, laid, &scroll_state, nid) + .and_then(|bounds| bounds.intersect(&needed_source)) + else { continue; }; let left = source_bounds.x.floor(); @@ -2094,11 +2404,10 @@ fn paint_laid_dom_scrolled( surface_offset.0 + layer_delta.0, surface_offset.1 + layer_delta.1, ), + raster_scale, )?; - let transform = display_transform.then(crate::Affine2::translate( - -layer_delta.0, - -layer_delta.1, - )); + let transform = + display_transform.then(crate::Affine2::translate(-layer_delta.0, -layer_delta.1)); let transform = Transform::from_row( transform.a, transform.b, @@ -2107,9 +2416,14 @@ fn paint_laid_dom_scrolled( transform.e, transform.f, ); - let clip_mask = outside_clip - .as_ref() - .and_then(|clip| box_clip_mask(pixmap.width(), pixmap.height(), clip)); + let clip_mask = outside_overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); pixmap.draw_pixmap( 0, 0, @@ -2125,8 +2439,8 @@ fn paint_laid_dom_scrolled( .styles .get(&nid) .and_then(|style| style.opacity) - .unwrap_or(1.0) - .clamp(0.0, 1.0); + .unwrap_or(1.0) + .clamp(0.0, 1.0); if suppress_opacity_for != Some(nid) && own_opacity < 1.0 { opacity_subtree_skip.insert(nid); opacity_subtree_skip.extend(tree.descendants(nid)); @@ -2158,6 +2472,7 @@ fn paint_laid_dom_scrolled( clip_scope_root, surface_extent, surface_offset, + raster_scale, )?; let group_paint = tiny_skia::PixmapPaint { opacity: own_opacity, @@ -2185,13 +2500,29 @@ fn paint_laid_dom_scrolled( // state, but it must not move with this descendant: that is what lets a // clip cull a slide the carousel track translated out of its viewport. let (ox, oy) = scroll_state.translation_for(laid, nid); - let rect = crate::Rect { x: rect.x + ox, y: rect.y + oy, width: rect.width, height: rect.height }; + let rect = crate::Rect { + x: rect.x + ox, + y: rect.y + oy, + width: rect.width, + height: rect.height, + }; // Ancestor `overflow: hidden` clip, if any. Skip painting entirely // once the box has no visible overlap with it (this is what makes the // ubiquitous 1x1 clipped "visually hidden" accessibility pattern // actually invisible instead of painting text wherever it lands). - let clip = scroll_state.clip_for(laid, nid); + let overflow_clip = scroll_state.overflow_clip_for(laid, nid); + let clip = overflow_clip + .as_ref() + .map(|clip| clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport))); + let ancestor_clip_mask = overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); let cull_rect = rect; let visible_rect = match clip { Some(c) => match cull_rect.intersect(&c) { @@ -2243,10 +2574,12 @@ fn paint_laid_dom_scrolled( &rect, style, clip, + ancestor_clip_mask.as_ref(), root_font_size, viewport, base_url, image_cache, + raster_scale, ); } @@ -2255,9 +2588,15 @@ fn paint_laid_dom_scrolled( // ancestor overflow clip is reapplied inside so the shadow is clipped by // an ancestor exactly as the box itself is. if !paints_inline_fragments { - if let Some(shadow) = style.box_shadow { - paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_model.radii, clip); - } + if let Some(shadow) = style.box_shadow { + paint_box_shadow( + &mut pixmap, + &shadow, + &rect, + style.border_model.radii, + ancestor_clip_mask.as_ref(), + ); + } } // Background positioning and clipping are independent. Keep the @@ -2268,7 +2607,9 @@ fn paint_laid_dom_scrolled( let has_radius = !radius.is_zero(); let background = background_geometry(&rect, style); let background_path = background_clip_path(background); - let background_mask = background_extra_clip(&pixmap, clip, clip_path_mask.as_ref()); + let element_clip_mask = + background_extra_clip(ancestor_clip_mask.as_ref(), clip_path_mask.as_ref()); + let background_mask = element_clip_mask.clone(); // A linear-gradient background (heavily used by modern hero sections); // without this it paints white. Takes precedence over a solid color. // `background-clip: text` clips the background to the glyphs, so it must @@ -2283,7 +2624,7 @@ fn paint_laid_dom_scrolled( path, &paint, FillRule::Winding, - Transform::identity(), + raster_transform(raster_scale), background_mask.as_ref(), ); } @@ -2303,98 +2644,101 @@ fn paint_laid_dom_scrolled( ); } } else { - if let Some((center, stops)) = &style.background_radial_gradient { - if let Some(path) = background_path.as_ref() { - paint_radial_gradient( + if let Some((center, stops)) = &style.background_radial_gradient { + if let Some(path) = background_path.as_ref() { + paint_radial_gradient( + &mut pixmap, + path, + &background.origin_rect, + *center, + stops, + background_mask.as_ref(), + ); + } + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient_sampled( &mut pixmap, - path, + &background.clip_rect, &background.origin_rect, + background.clip_radii, + *angle, *center, stops, background_mask.as_ref(), ); } - } - if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient_sampled( - &mut pixmap, - &background.clip_rect, - &background.origin_rect, - background.clip_radii, - *angle, - *center, - stops, - background_mask.as_ref(), - ); - } - if let Some((angle, stops)) = &style.background_gradient { - if let Some(path) = background_path.as_ref() { - paint_linear_gradient( - &mut pixmap, - path, - &background.origin_rect, - *angle, - stops, - background_mask.as_ref(), - ); + if let Some((angle, stops)) = &style.background_gradient { + if let Some(path) = background_path.as_ref() { + paint_linear_gradient( + &mut pixmap, + path, + &background.origin_rect, + *angle, + stops, + background_mask.as_ref(), + ); + } } } } - } if !paints_inline_fragments { - if let Some(mask_url) = &style.mask_image { - let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]); - paint_mask( - mask_url, - base_url, - &visible_rect, - radius, - fill, - style.background_radial_gradient.as_ref(), - style.background_gradient.as_ref(), - style.background_conic_gradient.as_ref(), - style.mask_size, - style.mask_repeat, - clip_path_mask.as_ref(), - &mut pixmap, - image_cache, - ); - } else if let Some(bg_url) = &style.background_image { - if let Some(img_rect) = background_image_rect( - bg_url, - base_url, - &background.origin_rect, - style.background_size, - style.background_size_expression.as_deref(), - style.background_size_fit, - style.background_position, - style.font_size.unwrap_or(16.0), - root_font_size, - viewport, - image_cache, - ) { - // A background layer is always clipped to its owner's border - // box and then to inherited overflow. Keep its full destination - // rect separate from that clip: intersecting first and then - // scaling would resize a partially clipped image. - if background.clip_rect.width > 0.0 && background.clip_rect.height > 0.0 { - paint_image( - bg_url, - base_url, - &img_rect, - &background.clip_rect, - crate::ObjectFit::Fill, - &mut pixmap, - image_cache, - None, - background.clip_radii, - background_mask.as_ref(), - ); + if let Some(mask_url) = &style.mask_image { + let fill = style + .background_color + .or(style.color) + .unwrap_or([0, 0, 0, 255]); + paint_mask( + mask_url, + base_url, + &visible_rect, + radius, + fill, + style.background_radial_gradient.as_ref(), + style.background_gradient.as_ref(), + style.background_conic_gradient.as_ref(), + style.mask_size, + style.mask_repeat, + element_clip_mask.as_ref(), + &mut pixmap, + image_cache, + ); + } else if let Some(bg_url) = &style.background_image { + if let Some(img_rect) = background_image_rect( + bg_url, + base_url, + &background.origin_rect, + style.background_size, + style.background_size_expression.as_deref(), + style.background_size_fit, + style.background_position, + style.font_size.unwrap_or(16.0), + root_font_size, + viewport, + image_cache, + ) { + // A background layer is always clipped to its owner's border + // box and then to inherited overflow. Keep its full destination + // rect separate from that clip: intersecting first and then + // scaling would resize a partially clipped image. + if background.clip_rect.width > 0.0 && background.clip_rect.height > 0.0 { + paint_image( + bg_url, + base_url, + &img_rect, + &background.clip_rect, + crate::ObjectFit::Fill, + &mut pixmap, + image_cache, + None, + background.clip_radii, + background_mask.as_ref(), + ); + } } } } - } let positioned_pseudo_containing_block = { let mut ancestor = Some(nid); let mut found = None; @@ -2429,13 +2773,25 @@ fn paint_laid_dom_scrolled( } found.unwrap_or(rect) }; - let positioned_pseudo_clip = scroll_state.descendant_clip_for(laid, nid); + let positioned_pseudo_overflow_clip = scroll_state.descendant_overflow_clip_for(laid, nid); + let positioned_pseudo_clip = positioned_pseudo_overflow_clip + .as_ref() + .map(|clip| clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport))); + let positioned_pseudo_clip_mask = + positioned_pseudo_overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); for pseudo in [ style.before_pseudo.as_deref(), style.after_pseudo.as_deref(), ] - .into_iter() - .flatten() + .into_iter() + .flatten() { paint_positioned_pseudo( &mut laid.text_engine, @@ -2446,8 +2802,10 @@ fn paint_laid_dom_scrolled( viewport, root_font_size, positioned_pseudo_clip, + positioned_pseudo_clip_mask.as_ref(), base_url, image_cache, + raster_scale, ); } @@ -2456,14 +2814,16 @@ fn paint_laid_dom_scrolled( &mut pixmap, &rect, style, - clip_path_mask.as_ref(), + element_clip_mask.as_ref(), + raster_scale, ); } paint_css_outline( &mut pixmap, &rect, style, - clip_path_mask.as_ref(), + element_clip_mask.as_ref(), + raster_scale, ); if name.local.as_ref() == "img" { @@ -2473,17 +2833,17 @@ fn paint_laid_dom_scrolled( // half-scrolled carousel slide's image otherwise bleeds over // the viewport edge). let painted = paint_image( - &source.resolved_url, - None, - &rect, - &visible_rect, - style.object_fit, - &mut pixmap, - image_cache, - None, - radius, - clip_path_mask.as_ref(), - ); + &source.resolved_url, + None, + &rect, + &visible_rect, + style.object_fit, + &mut pixmap, + image_cache, + None, + radius, + element_clip_mask.as_ref(), + ); // Fall back when the image itself did not paint, following // what browsers show for a broken image: a non-empty alt // renders as text in place of the image (no placeholder box), @@ -2506,6 +2866,8 @@ fn paint_laid_dom_scrolled( None, 0.0, clip, + element_clip_mask.as_ref(), + raster_scale, ); } Some(_) => {} @@ -2513,7 +2875,12 @@ fn paint_laid_dom_scrolled( if visible_rect.width >= 4.0 && visible_rect.height >= 4.0 { let mut ph = Paint::default(); ph.set_color(Color::from_rgba8(0xE9, 0xEA, 0xEC, 0xFF)); - pixmap.fill_rect(box_rect, &ph, Transform::identity(), None); + pixmap.fill_rect( + box_rect, + &ph, + raster_transform(raster_scale), + None, + ); } } } @@ -2539,7 +2906,14 @@ fn paint_laid_dom_scrolled( // the standalone document. A document-level/external symbol may // itself contain `currentColor`, and therefore has to be present // when the root color is established. - inject_external_sprites(tree, nid, base_url, &mut markup, image_cache, &mut sprite_cache); + inject_external_sprites( + tree, + nid, + base_url, + &mut markup, + image_cache, + &mut sprite_cache, + ); // resvg parses the serialized subtree as a standalone SVG // document, outside the page's author stylesheet. Preserve the // host element's computed `color` so paths using `currentColor` @@ -2559,16 +2933,16 @@ fn paint_laid_dom_scrolled( rect.height as u32, &svg_fonts, ) { - let mask = if clip.is_some() || has_radius { - rounded_box_clip_mask_radii( + let mut mask = element_clip_mask.clone(); + if has_radius { + let own = rounded_box_clip_mask_radii( pixmap.width(), pixmap.height(), &visible_rect, radius, - ) - } else { - None - }; + ); + mask = intersect_clip_masks(mask, own.as_ref()); + } pixmap.draw_pixmap( rect.x as i32, rect.y as i32, @@ -2594,6 +2968,7 @@ fn paint_laid_dom_scrolled( root_font_size, base_url, image_cache, + raster_scale, ); } } @@ -2619,6 +2994,8 @@ fn paint_laid_dom_scrolled( style.font_family.as_deref(), style.letter_spacing.unwrap_or(0.0), clip, + element_clip_mask.as_ref(), + raster_scale, ); } } @@ -2643,6 +3020,8 @@ fn paint_laid_dom_scrolled( style.font_family.as_deref(), style.letter_spacing.unwrap_or(0.0), clip, + element_clip_mask.as_ref(), + raster_scale, ); } } @@ -2667,6 +3046,8 @@ fn paint_laid_dom_scrolled( style.font_family.as_deref(), style.letter_spacing.unwrap_or(0.0), Some(visible_rect), + element_clip_mask.as_ref(), + raster_scale, ); } if rect.width >= 12.0 && rect.height >= 8.0 { @@ -2680,15 +3061,14 @@ fn paint_laid_dom_scrolled( if let Some(arrow) = arrow.finish() { let mut arrow_paint = Paint::default(); let color = style.color.unwrap_or([0, 0, 0, 255]); - arrow_paint.set_color(Color::from_rgba8( - color[0], color[1], color[2], color[3], - )); + arrow_paint + .set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); pixmap.fill_path( &arrow, &arrow_paint, FillRule::Winding, - Transform::identity(), - None, + raster_transform(raster_scale), + element_clip_mask.as_ref(), ); } } @@ -2699,7 +3079,10 @@ fn paint_laid_dom_scrolled( // not real content), so paint it directly from the attribute instead // of going through `paint_text_node`. if name.local.as_ref() == "input" || name.local.as_ref() == "textarea" { - let has_value = node.get_attribute("value").map(|v| !v.is_empty()).unwrap_or(false); + let has_value = node + .get_attribute("value") + .map(|v| !v.is_empty()) + .unwrap_or(false); if !has_value { if let Some(placeholder) = node.get_attribute("placeholder") { if !placeholder.is_empty() { @@ -2717,6 +3100,8 @@ fn paint_laid_dom_scrolled( style.font_family.as_deref(), style.letter_spacing.unwrap_or(0.0), clip, + element_clip_mask.as_ref(), + raster_scale, ); } } @@ -2732,6 +3117,7 @@ fn paint_laid_dom_scrolled( root_font_size, base_url, image_cache, + raster_scale, ); } } @@ -2749,24 +3135,52 @@ fn paint_laid_dom_scrolled( if whole.is_none() && run_items.is_none() { continue; } - if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) { + if laid + .styles + .get(&nid) + .map(|s| s.effectively_invisible) + .unwrap_or(false) + { continue; } // Shift the shaped glyphs by the same accumulated translate as the // container's box so text under a transformed ancestor moves with // it. Computed before the mutable `paint_item` borrow. let off = scroll_state.translation_for(laid, nid); - let clip = scroll_state.shaped_text_clip_for(laid, nid); + let overflow_clip = scroll_state.shaped_text_overflow_clip_for(laid, nid); + let clip = overflow_clip + .as_ref() + .map(|clip| clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport))); + let clip_mask = overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); if let Some(idx) = whole { - laid.text_engine - .paint_item_with_clip(idx, &mut pixmap, off, clip); + laid.text_engine.paint_item_with_clip_mask_scaled( + idx, + &mut pixmap, + off, + clip, + clip_mask.as_ref(), + raster_scale, + ); } // Anonymous inline-run leaves of a mixed block (see // `build_mixed_block`), pinned to their own boxes at finalize. if let Some(items) = run_items { for idx in items { - laid.text_engine - .paint_item_with_clip(idx, &mut pixmap, off, clip); + laid.text_engine.paint_item_with_clip_mask_scaled( + idx, + &mut pixmap, + off, + clip, + clip_mask.as_ref(), + raster_scale, + ); } } } @@ -2774,6 +3188,10 @@ fn paint_laid_dom_scrolled( Some(pixmap) } +fn raster_transform(scale: f32) -> Transform { + Transform::from_scale(scale, scale) +} + /// Paint an ordinary inline's sliced border boxes instead of its multiline /// bounding union. Background coordinates stay anchored to the union so /// gradients and images remain one joined `box-decoration-break:slice` @@ -2786,10 +3204,12 @@ fn paint_inline_fragment_decorations( union: &crate::Rect, style: &crate::LayoutStyle, clip: Option, + ancestor_clip_mask: Option<&tiny_skia::Mask>, root_font_size: f32, viewport: (f32, f32), base_url: Option<&str>, image_cache: &mut RenderResourceCache, + raster_scale: f32, ) { let union = crate::Rect { x: union.x, @@ -2843,7 +3263,8 @@ fn paint_inline_fragment_decorations( // clip but must not move the shared background coordinate system. let background_origin = background_geometry(&union, style).origin_rect; let background_path = background_clip_path(background); - let background_mask = background_extra_clip(pixmap, clip, clip_path_mask.as_ref()); + let element_clip_mask = background_extra_clip(ancestor_clip_mask, clip_path_mask.as_ref()); + let background_mask = element_clip_mask.clone(); if let Some(shadow) = fragment_style.box_shadow { paint_box_shadow( @@ -2851,7 +3272,7 @@ fn paint_inline_fragment_decorations( &shadow, &fragment, fragment_style.border_model.radii, - clip, + ancestor_clip_mask, ); } if fragment_style.mask_image.is_none() && !fragment_style.background_clip_text { @@ -2859,15 +3280,13 @@ fn paint_inline_fragment_decorations( (fragment_style.background_color, background_path.as_ref()) { let mut paint = Paint::default(); - paint.set_color(Color::from_rgba8( - color[0], color[1], color[2], color[3], - )); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); paint.anti_alias = !background.clip_radii.is_zero(); pixmap.fill_path( path, &paint, FillRule::Winding, - Transform::identity(), + raster_transform(raster_scale), background_mask.as_ref(), ); } @@ -2886,9 +3305,10 @@ fn paint_inline_fragment_decorations( ); } } else { - if let (Some((center, stops)), Some(path)) = - (&fragment_style.background_radial_gradient, background_path.as_ref()) - { + if let (Some((center, stops)), Some(path)) = ( + &fragment_style.background_radial_gradient, + background_path.as_ref(), + ) { paint_radial_gradient( pixmap, path, @@ -2910,9 +3330,10 @@ fn paint_inline_fragment_decorations( background_mask.as_ref(), ); } - if let (Some((angle, stops)), Some(path)) = - (&fragment_style.background_gradient, background_path.as_ref()) - { + if let (Some((angle, stops)), Some(path)) = ( + &fragment_style.background_gradient, + background_path.as_ref(), + ) { paint_linear_gradient( pixmap, path, @@ -2941,7 +3362,7 @@ fn paint_inline_fragment_decorations( fragment_style.background_conic_gradient.as_ref(), fragment_style.mask_size, fragment_style.mask_repeat, - clip_path_mask.as_ref(), + element_clip_mask.as_ref(), pixmap, image_cache, ); @@ -2973,7 +3394,13 @@ fn paint_inline_fragment_decorations( ); } } - paint_css_border(pixmap, &fragment, &fragment_style, clip_path_mask.as_ref()); + paint_css_border( + pixmap, + &fragment, + &fragment_style, + element_clip_mask.as_ref(), + raster_scale, + ); } } @@ -3096,7 +3523,10 @@ impl<'a> ScrollPaintState<'a> { ); } if !self.active || self.viewport_fixed.contains(&id) { - return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1); + return ( + base.0 + self.surface_offset.0, + base.1 + self.surface_offset.1, + ); } let sticky = self.sticky.get(&id).copied().unwrap_or((0.0, 0.0)); ( @@ -3105,15 +3535,6 @@ impl<'a> ScrollPaintState<'a> { ) } - fn clip_for( - &self, - laid: &crate::DomLayout, - id: obscura_dom::tree::NodeId, - ) -> Option { - self.overflow_clip_for(laid, id) - .map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport))) - } - fn overflow_clip_for( &self, laid: &crate::DomLayout, @@ -3160,55 +3581,44 @@ impl<'a> ScrollPaintState<'a> { clip.translate(self.surface_offset.0, self.surface_offset.1); return Some(clip); } - let mut clip = laid.clip_rects.get(&id).copied().flatten()?; + let mut clip = laid.clip_rects.get(&id).cloned().flatten()?; if self.active && !self.viewport_fixed.contains(&id) { - let sticky = self - .sticky_clips - .get(&id) - .copied() - .unwrap_or((0.0, 0.0)); - clip.translate( - sticky.0 - self.scroll.0, - sticky.1 - self.scroll.1, - ); + let sticky = self.sticky_clips.get(&id).copied().unwrap_or((0.0, 0.0)); + clip.translate(sticky.0 - self.scroll.0, sticky.1 - self.scroll.1); } clip.translate(self.surface_offset.0, self.surface_offset.1); Some(clip) } - fn descendant_clip_for( + fn descendant_overflow_clip_for( &self, laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, - ) -> Option { + ) -> Option { let inherited = self.overflow_clip_for(laid, id); - let style = laid.styles.get(&id)?; + let Some(style) = laid.styles.get(&id) else { + return inherited; + }; if !style.overflow_hidden || style.overflow_propagated_to_viewport { - return inherited - .map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport))); + return inherited; } - let rect = laid.rects.get(&id)?; + let Some(rect) = laid.rects.get(&id) else { + return inherited; + }; let (ox, oy) = self.translation_for(laid, id); let own = crate::dom::OverflowClip::for_box(rect, style, ox, oy); - Some( - match inherited { - Some(clip) => clip.intersect(own), - None => own, - } - .viewport_rect(self.surface_extent.unwrap_or(self.viewport)), - ) + Some(match inherited { + Some(clip) => clip.intersect(own), + None => own, + }) } - /// Capture-space clip for a shaped inline context. `clip_for` supplies the - /// adjusted ancestor chain; text owned by an `overflow:hidden` element - /// also needs that element's own padding-box clip in the same viewport - /// coordinates. - fn shaped_text_clip_for( + fn shaped_text_overflow_clip_for( &self, laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, - ) -> Option { - self.descendant_clip_for(laid, id) + ) -> Option { + self.descendant_overflow_clip_for(laid, id) } } @@ -3216,14 +3626,7 @@ impl<'a> ScrollPaintState<'a> { /// (visually indistinguishable from true arcs at typical UI radii). The /// horizontal and vertical radii are scaled together when necessary, matching /// CSS's overlap rule while preserving percentage ellipses. -fn rounded_rect_path( - x: f32, - y: f32, - w: f32, - h: f32, - rx: f32, - ry: f32, -) -> Option { +fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, rx: f32, ry: f32) -> Option { rounded_rect_path_radii( x, y, @@ -3305,6 +3708,7 @@ fn paint_css_border( rect: &crate::Rect, style: &crate::LayoutStyle, mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let widths = crate::Sides { top: style.border.top, @@ -3348,6 +3752,7 @@ fn paint_css_border( colors.top, radii, mask, + raster_scale, ); return; } @@ -3364,7 +3769,16 @@ fn paint_css_border( } match line_style { crate::BorderStyle::Solid | crate::BorderStyle::Auto => { - fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + fill_solid_border_side( + pixmap, + rect, + widths, + radii, + side, + color, + mask, + raster_scale, + ); } crate::BorderStyle::Inset | crate::BorderStyle::Outset @@ -3380,11 +3794,29 @@ fn paint_css_border( _ => false, }; let color = shade_border_color(color, if dark_side { -0.28 } else { 0.28 }); - fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + fill_solid_border_side( + pixmap, + rect, + widths, + radii, + side, + color, + mask, + raster_scale, + ); } crate::BorderStyle::Double => { if width < 3.0 { - fill_solid_border_side(pixmap, rect, widths, radii, side, color, mask); + fill_solid_border_side( + pixmap, + rect, + widths, + radii, + side, + color, + mask, + raster_scale, + ); } else { paint_straight_border_side( pixmap, @@ -3395,6 +3827,7 @@ fn paint_css_border( color, None, mask, + raster_scale, ); paint_straight_border_side( pixmap, @@ -3405,6 +3838,7 @@ fn paint_css_border( color, None, mask, + raster_scale, ); } } @@ -3418,6 +3852,7 @@ fn paint_css_border( color, Some(line_style), mask, + raster_scale, ); } crate::BorderStyle::None | crate::BorderStyle::Hidden => {} @@ -3430,6 +3865,7 @@ fn paint_css_outline( rect: &crate::Rect, style: &crate::LayoutStyle, mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let width = style.outline.used_width(); if width <= 0.0 { @@ -3463,6 +3899,7 @@ fn paint_css_outline( color, radii, mask, + raster_scale, ); } @@ -3474,6 +3911,7 @@ fn paint_uniform_border( color: [u8; 4], radii: crate::ResolvedBorderRadii, mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { if width <= 0.0 || !line_style.is_visible() { return; @@ -3496,6 +3934,7 @@ fn paint_uniform_border( color, radii.inset(crate::Sides::all(inset)), mask, + raster_scale, ); } } @@ -3519,6 +3958,7 @@ fn paint_uniform_border( color, radii.inset(crate::Sides::all(center)), mask, + raster_scale, ); } @@ -3530,6 +3970,7 @@ fn stroke_rounded_border_path( color: [u8; 4], radii: crate::ResolvedBorderRadii, mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let Some(path) = rounded_rect_path_radii( center_rect.x, @@ -3557,7 +3998,7 @@ fn stroke_rounded_border_path( } _ => {} } - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask); + pixmap.stroke_path(&path, &paint, &stroke, raster_transform(raster_scale), mask); } fn fill_solid_border_side( @@ -3568,6 +4009,7 @@ fn fill_solid_border_side( side: Side, color: [u8; 4], mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let Some(path) = solid_border_side_path(rect, widths, radii, side) else { return; @@ -3575,7 +4017,13 @@ fn fill_solid_border_side( let mut paint = Paint::default(); paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); paint.anti_alias = !radii.is_zero(); - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), mask); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + raster_transform(raster_scale), + mask, + ); } fn arc_point(center: (f32, f32), radius: (f32, f32), degrees: f32) -> (f32, f32) { @@ -3624,11 +4072,24 @@ fn solid_border_side_path( let inner_centers = [ (ix + inner.top_left.0, iy + inner.top_left.1), (iright - inner.top_right.0, iy + inner.top_right.1), - (iright - inner.bottom_right.0, ibottom - inner.bottom_right.1), + ( + iright - inner.bottom_right.0, + ibottom - inner.bottom_right.1, + ), (ix + inner.bottom_left.0, ibottom - inner.bottom_left.1), ]; - let outer_radii = [outer.top_left, outer.top_right, outer.bottom_right, outer.bottom_left]; - let inner_radii = [inner.top_left, inner.top_right, inner.bottom_right, inner.bottom_left]; + let outer_radii = [ + outer.top_left, + outer.top_right, + outer.bottom_right, + outer.bottom_left, + ]; + let inner_radii = [ + inner.top_left, + inner.top_right, + inner.bottom_right, + inner.bottom_left, + ]; let (a, b, outer_a, outer_b, inner_b, inner_a) = match side { Side::Top => (225.0, 270.0, 0, 1, 1, 0), Side::Right => (315.0, 360.0, 1, 2, 2, 1), @@ -3650,7 +4111,13 @@ fn solid_border_side_path( let mut path = PathBuilder::new(); let start = arc_point(outer_centers[outer_a], outer_radii[outer_a], a); path.move_to(start.0, start.1); - append_arc(&mut path, outer_centers[outer_a], outer_radii[outer_a], a, b); + append_arc( + &mut path, + outer_centers[outer_a], + outer_radii[outer_a], + a, + b, + ); append_arc( &mut path, outer_centers[outer_b], @@ -3685,6 +4152,7 @@ fn paint_straight_border_side( color: [u8; 4], pattern: Option, mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { let mut path = PathBuilder::new(); match side { @@ -3713,15 +4181,12 @@ fn paint_straight_border_side( ..Default::default() }; if pattern == Some(crate::BorderStyle::Dashed) { - stroke.dash = tiny_skia::StrokeDash::new( - vec![stroke_width * 3.0, stroke_width * 3.0], - 0.0, - ); + stroke.dash = tiny_skia::StrokeDash::new(vec![stroke_width * 3.0, stroke_width * 3.0], 0.0); } else if pattern == Some(crate::BorderStyle::Dotted) { stroke.dash = tiny_skia::StrokeDash::new(vec![0.0, stroke_width * 2.0], 0.0); stroke.line_cap = tiny_skia::LineCap::Round; } - pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), mask); + pixmap.stroke_path(&path, &paint, &stroke, raster_transform(raster_scale), mask); } fn shade_border_color(color: [u8; 4], amount: f32) -> [u8; 4] { @@ -3732,7 +4197,12 @@ fn shade_border_color(color: [u8; 4], amount: f32) -> [u8; 4] { .round() .clamp(0.0, 255.0) as u8 }; - [channel(color[0]), channel(color[1]), channel(color[2]), color[3]] + [ + channel(color[0]), + channel(color[1]), + channel(color[2]), + color[3], + ] } /// Paint an outset `box-shadow` layer behind the element's own box. `rect` is @@ -3750,7 +4220,7 @@ fn paint_box_shadow( shadow: &crate::BoxShadow, rect: &crate::Rect, border_radius: crate::BorderRadii, - clip: Option, + ancestor_clip: Option<&tiny_skia::Mask>, ) { if shadow.inset || shadow.color[3] == 0 { return; @@ -3767,20 +4237,12 @@ fn paint_box_shadow( let rx0 = (radius.0 + spread).max(0.0); let ry0 = (radius.1 + spread).max(0.0); let blur = shadow.blur.max(0.0); - // Ancestor overflow clip: build a mask once and reuse it for every layer. - let mask = match clip { - Some(c) => { - if c.width <= 0.0 || c.height <= 0.0 { - return; - } - box_clip_mask(pixmap.width(), pixmap.height(), &c) - } - None => None, - }; + // Ancestor overflow clip is already the complete rect/rounded chain. + let mask = ancestor_clip; let color = shadow.color; if blur < 0.5 { // No blur: a single crisp, offset (and spread) rounded rect. - fill_shadow_rect(pixmap, x0, y0, w0, h0, rx0, ry0, color, mask.as_ref()); + fill_shadow_rect(pixmap, x0, y0, w0, h0, rx0, ry0, color, mask); return; } let steps: u32 = (blur.ceil() as u32).clamp(2, 24); @@ -3803,7 +4265,7 @@ fn paint_box_shadow( rx0 + e, ry0 + e, layer_color, - mask.as_ref(), + mask, ); } } @@ -3844,13 +4306,23 @@ fn fill_shadow_rect( let mut paint = Paint::default(); paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); paint.anti_alias = true; - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), mask); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + mask, + ); } /// The marker text for a list item, or `None` when markers are suppressed /// (`list-style: none`). `Decimal` numbers the item by its position among /// sibling list items so `
    `s count 1, 2, 3. -fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Option) -> Option { +fn list_marker_text( + tree: &DomTree, + nid: obscura_dom::tree::NodeId, + style: Option, +) -> Option { match style { Some(crate::ListStyle::Disc) => Some("\u{2022}".to_string()), Some(crate::ListStyle::Circle) => Some("\u{25E6}".to_string()), @@ -3859,7 +4331,12 @@ fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Optio let mut n = 1usize; let mut cur = tree.get_node(nid).and_then(|node| node.prev_sibling); while let Some(sib) = cur { - if tree.get_node(sib).and_then(|s| s.as_element().map(|e| e.local.to_string())).as_deref() == Some("li") { + if tree + .get_node(sib) + .and_then(|s| s.as_element().map(|e| e.local.to_string())) + .as_deref() + == Some("li") + { n += 1; } cur = tree.get_node(sib).and_then(|s| s.prev_sibling); @@ -3870,13 +4347,12 @@ fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Optio } } -fn selected_option_label( - tree: &DomTree, - select: obscura_dom::tree::NodeId, -) -> Option { +fn selected_option_label(tree: &DomTree, select: obscura_dom::tree::NodeId) -> Option { let mut first = None; for option_id in tree.descendants(select) { - let Some(option) = tree.get_node(option_id) else { continue }; + let Some(option) = tree.get_node(option_id) else { + continue; + }; if option .as_element() .map_or(true, |name| name.local.as_ref() != "option") @@ -3899,7 +4375,11 @@ fn selected_option_label( /// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the /// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`. -pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option> { +pub fn screenshot_png( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, +) -> Option> { paint_dom(tree, viewport, base_url)?.encode_png().ok() } @@ -3910,8 +4390,7 @@ pub fn screenshot_png_scrolled( base_url: Option<&str>, scroll: (f32, f32), ) -> Option> { - paint_dom_scrolled(tree, viewport, base_url, scroll) - .and_then(|pixmap| pixmap.encode_png().ok()) + paint_dom_scrolled(tree, viewport, base_url, scroll).and_then(|pixmap| pixmap.encode_png().ok()) } /// PNG convenience wrapper for a retained resource-aware layout. @@ -3968,7 +4447,10 @@ fn clip_text_fill_color(style: &crate::LayoutStyle) -> Option<[u8; 4]> { return Some([mid[0], mid[1], mid[2], 255]); } } - style.background_color.filter(|c| c[3] != 0).map(|c| [c[0], c[1], c[2], 255]) + style + .background_color + .filter(|c| c[3] != 0) + .map(|c| [c[0], c[1], c[2], 255]) } /// Paint every word of a text node at its own laid-out position. A text node @@ -3982,6 +4464,7 @@ fn paint_text_node( laid: &crate::DomLayout, scroll_state: &ScrollPaintState, pixmap: &mut Pixmap, + raster_scale: f32, ) -> Option<()> { let runs = laid.text_runs.get(&nid)?; let node = tree.get_node(nid)?; @@ -3990,7 +4473,8 @@ fn paint_text_node( if style.effectively_invisible { return Some(()); } - let color = clip_text_fill_color(style).unwrap_or_else(|| style.color.unwrap_or([0, 0, 0, 255])); + let color = + clip_text_fill_color(style).unwrap_or_else(|| style.color.unwrap_or([0, 0, 0, 255])); let fsize = style.font_size.unwrap_or(16.0); let is_bold = crate::style::used_font_weight(style) >= 600; // A text node has no transform of its own, but any transformed element @@ -3998,7 +4482,18 @@ fn paint_text_node( // receives root-scroll/sticky movement without following the descendant's // own transform. let (ox, oy) = scroll_state.translation_for(laid, nid); - let clip = scroll_state.clip_for(laid, nid); + let overflow_clip = scroll_state.overflow_clip_for(laid, nid); + let clip = overflow_clip.as_ref().map(|clip| { + clip.viewport_rect(scroll_state.surface_extent.unwrap_or(scroll_state.viewport)) + }); + let clip_mask = overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(scroll_state.viewport), + ) + }); for (rect, word) in runs { draw_text( @@ -4012,13 +4507,17 @@ fn paint_text_node( style.font_family.as_deref(), style.letter_spacing.unwrap_or(0.0), clip, + clip_mask.as_ref(), + raster_scale, ); } Some(()) } fn fallback_font_bytes(family: Option<&str>) -> &'static [u8] { - let Some(family) = family else { return FONT_BYTES }; + let Some(family) = family else { + return FONT_BYTES; + }; for token in family.split(',') { let token = token .trim() @@ -4071,10 +4570,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool, family: Option<&str>) let scaled_font = font.as_scaled(scale); let mut width = 0.0; for c in text.chars() { - if c.is_control() { continue; } + if c.is_control() { + continue; + } width += scaled_font.h_advance(font.glyph_id(c)); } - if is_bold { width += text.chars().filter(|c| !c.is_control()).count() as f32; } + if is_bold { + width += text.chars().filter(|c| !c.is_control()).count() as f32; + } width } @@ -4089,6 +4592,8 @@ fn draw_text( family: Option<&str>, letter_spacing: f32, clip: Option, + clip_mask: Option<&tiny_skia::Mask>, + raster_scale: f32, ) { // A fully clipped-away run (the common "visually hidden" accessibility // pattern: a 1x1 box with overflow: hidden) paints nothing at all. @@ -4098,13 +4603,20 @@ fn draw_text( } } let font = FontRef::try_from_slice(fallback_font_bytes(family)).unwrap(); - let scale = PxScale::from(size); + let scale = PxScale::from(size * raster_scale); let scaled_font = font.as_scaled(scale); - let mut caret = ab_glyph::point(x, y + scaled_font.ascent()); + let mut caret = ab_glyph::point(x * raster_scale, y * raster_scale + scaled_font.ascent()); let width = pixmap.width() as i32; let height = pixmap.height() as i32; - let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height)); + let clip_bounds = clip.map(|c| { + ( + c.x * raster_scale, + c.y * raster_scale, + (c.x + c.width) * raster_scale, + (c.y + c.height) * raster_scale, + ) + }); let pixels = pixmap.pixels_mut(); let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]); @@ -4121,7 +4633,11 @@ fn draw_text( let px = (bounds.min.x + gx as f32) as i32; let py = (bounds.min.y + gy as f32) as i32; if let Some((cx0, cy0, cx1, cy1)) = clip_bounds { - if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 { + if (px as f32) < cx0 + || (px as f32) >= cx1 + || (py as f32) < cy0 + || (py as f32) >= cy1 + { return; } } @@ -4129,28 +4645,46 @@ fn draw_text( let alpha = (a_full as f32 * c) as u8; if alpha > 0 { let mut px_indices = vec![(py * width + px) as usize]; - if is_bold && px + 1 < width { - px_indices.push((py * width + px + 1) as usize); + if is_bold { + for dx in 1..raster_scale.ceil().max(1.0) as i32 { + if px + dx < width { + px_indices.push((py * width + px + dx) as usize); + } + } } for idx in px_indices { + let mask_alpha = clip_mask + .and_then(|mask| mask.data().get(idx)) + .copied() + .unwrap_or(255) as u32; + let alpha = alpha as u32 * mask_alpha / 255; + if alpha == 0 { + continue; + } let dst = pixels[idx]; - - let src_a = alpha as u32; + + let src_a = alpha; let src_r = (r as u32 * src_a) / 255; let src_g = (g as u32 * src_a) / 255; let src_b = (b as u32 * src_a) / 255; - + let dst_a = dst.alpha() as u32; let out_a = src_a + (dst_a * (255 - src_a) / 255); - + if out_a > 0 { let out_r = src_r + (dst.red() as u32 * (255 - src_a) / 255); let out_g = src_g + (dst.green() as u32 * (255 - src_a) / 255); let out_b = src_b + (dst.blue() as u32 * (255 - src_a) / 255); - + pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba( - out_r as u8, out_g as u8, out_b as u8, out_a as u8 - ).unwrap_or_else(|| tiny_skia::PremultipliedColorU8::from_rgba(0,0,0,0).unwrap()); + out_r as u8, + out_g as u8, + out_b as u8, + out_a as u8, + ) + .unwrap_or_else(|| { + tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap() + }); } } } @@ -4162,12 +4696,12 @@ fn draw_text( // the difference shows up as a visible gap after every word once // each word is its own independently-positioned box. caret.x += scaled_font.h_advance(id) - + if is_bold { 1.0 } else { 0.0 } - + letter_spacing; + + if is_bold { raster_scale } else { 0.0 } + + letter_spacing * raster_scale; } else { caret.x += scaled_font.h_advance(id) - + if is_bold { 1.0 } else { 0.0 } - + letter_spacing; + + if is_bold { raster_scale } else { 0.0 } + + letter_spacing * raster_scale; } } } @@ -4243,7 +4777,9 @@ fn collect_web_fonts( let mut rules = Vec::new(); for nid in tree.descendants(tree.document()) { - let Some(node) = tree.get_node(nid) else { continue }; + let Some(node) = tree.get_node(nid) else { + continue; + }; if node .as_element() .map(|element| element.local.as_ref() != "style") @@ -4293,7 +4829,9 @@ fn collect_web_fonts( // the matching @font-face descriptors needed for CSS family/weight lookup. let mut preloads = Vec::new(); for nid in tree.descendants(tree.document()) { - let Some(node) = tree.get_node(nid) else { continue }; + let Some(node) = tree.get_node(nid) else { + continue; + }; if node .as_element() .map(|element| element.local.as_ref() != "link") @@ -4436,17 +4974,22 @@ fn font_face_declaration<'a>(face: &'a str, name: &str) -> Option<&'a str> { split_css_top_level(face, ';') .into_iter() .find_map(|declaration| { - let (property, value) = declaration.split_once(':')?; + let (property, value) = declaration.split_once(':')?; property .trim() .eq_ignore_ascii_case(name) .then_some(value.trim()) - }) + }) } fn font_face_family(face: &str) -> Option { font_face_declaration(face, "font-family") - .map(|family| family.trim().trim_matches(|ch| matches!(ch, '"' | '\'')).to_string()) + .map(|family| { + family + .trim() + .trim_matches(|ch| matches!(ch, '"' | '\'')) + .to_string() + }) .filter(|family| !family.is_empty()) } @@ -4586,10 +5129,7 @@ fn http_get_bytes(url: &str) -> Option> { for attempt in 0..3 { // Advertise only formats that this build can decode. Content-negotiating // CDNs otherwise commonly choose AVIF and leave the image blank. - let res = image_agent() - .get(url) - .set("Accept", IMAGE_ACCEPT) - .call(); + let res = image_agent().get(url).set("Accept", IMAGE_ACCEPT).call(); match res { Ok(resp) => { let mut buf = Vec::new(); @@ -4603,7 +5143,9 @@ fn http_get_bytes(url: &str) -> Option> { // minutes, so fast-fail to the grey placeholder instead. Real // fidelity for that case needs an HTTP/2 image client (multiplexing // like Chrome), not blocking retries. - Err(ureq::Error::Status(code, _)) if matches!(code, 429 | 500 | 502 | 503 | 504) && attempt < 2 => { + Err(ureq::Error::Status(code, _)) + if matches!(code, 429 | 500 | 502 | 503 | 504) && attempt < 2 => + { std::thread::sleep(backoff); backoff *= 2; } @@ -4712,11 +5254,19 @@ fn paint_linear_gradient( let mut last = 0.0f32; for (i, (_, pos)) in stops.iter().enumerate() { let c = gradient_stop_color(stops, i); - let p = pos.unwrap_or(i as f32 / (n - 1) as f32).clamp(0.0, 1.0).max(last); + let p = pos + .unwrap_or(i as f32 / (n - 1) as f32) + .clamp(0.0, 1.0) + .max(last); last = p; - gs.push(GradientStop::new(p, Color::from_rgba8(c[0], c[1], c[2], c[3]))); + gs.push(GradientStop::new( + p, + Color::from_rgba8(c[0], c[1], c[2], c[3]), + )); } - if let Some(shader) = LinearGradient::new(start, end, gs, SpreadMode::Pad, Transform::identity()) { + if let Some(shader) = + LinearGradient::new(start, end, gs, SpreadMode::Pad, Transform::identity()) + { let mut paint = Paint::default(); paint.shader = shader; paint.anti_alias = true; @@ -4829,7 +5379,7 @@ fn paint_linear_gradient_layer( ((positions[index].unwrap_or(origin) - origin) / span).clamp(0.0, 1.0) } None => positions[index].unwrap_or(0.0).clamp(0.0, 1.0), - } + } .max(monotonic); monotonic = position; gradient_stops.push(GradientStop::new( @@ -4931,39 +5481,10 @@ fn background_clip_path(geometry: BackgroundGeometry) -> Option } fn background_extra_clip( - pixmap: &Pixmap, - ancestor_clip: Option, + ancestor_clip: Option<&tiny_skia::Mask>, polygon_clip: Option<&tiny_skia::Mask>, ) -> Option { - let mut mask = polygon_clip.cloned(); - if let Some(clip) = ancestor_clip { - let path = Rect::from_xywh(clip.x, clip.y, clip.width, clip.height).and_then(|rect| { - let mut builder = PathBuilder::new(); - builder.push_rect(rect); - builder.finish() - }); - if let Some(path) = path { - match mask.as_mut() { - Some(mask) => mask.intersect_path( - &path, - FillRule::Winding, - true, - Transform::identity(), - ), - None => { - let mut new_mask = tiny_skia::Mask::new(pixmap.width(), pixmap.height())?; - new_mask.fill_path( - &path, - FillRule::Winding, - true, - Transform::identity(), - ); - mask = Some(new_mask); - } - } - } - } - mask + intersect_clip_masks(ancestor_clip.cloned(), polygon_clip) } fn paint_radial_gradient( @@ -5028,8 +5549,7 @@ fn paint_background_gradient_layers( ) { let layers = &style.background_gradient_layers; let em = style.font_size.unwrap_or(16.0); - let tile_size = - background_gradient_tile_size(style, origin_rect, em, root_font_size, viewport); + let tile_size = background_gradient_tile_size(style, origin_rect, em, root_font_size, viewport); let clip_differs_from_origin = (clip_rect.x - origin_rect.x).abs() > 0.01 || (clip_rect.y - origin_rect.y).abs() > 0.01 || (clip_rect.width - origin_rect.width).abs() > 0.01 @@ -5300,21 +5820,10 @@ fn paint_conic_gradient_sampled( } let mut clip = extra_clip.cloned(); if !border_radius.is_zero() { - let path = rounded_rect_path_radii( - rect.x, - rect.y, - rect.width, - rect.height, - border_radius, - ); + let path = rounded_rect_path_radii(rect.x, rect.y, rect.width, rect.height, border_radius); match (clip.as_mut(), path) { (Some(mask), Some(path)) => { - mask.intersect_path( - &path, - FillRule::Winding, - true, - Transform::identity(), - ); + mask.intersect_path(&path, FillRule::Winding, true, Transform::identity()); } (None, _) => { clip = rounded_box_clip_mask_radii( @@ -5337,9 +5846,7 @@ fn paint_conic_gradient_sampled( ); } -fn normalized_stops( - stops: &[([u8; 4], Option)], -) -> Vec<(f32, [u8; 4])> { +fn normalized_stops(stops: &[([u8; 4], Option)]) -> Vec<(f32, [u8; 4])> { let count = stops.len(); let mut normalized = Vec::with_capacity(count); let mut last = 0.0f32; @@ -5361,10 +5868,7 @@ fn normalized_stops( normalized } -fn gradient_stop_color( - stops: &[([u8; 4], Option)], - index: usize, -) -> [u8; 4] { +fn gradient_stop_color(stops: &[([u8; 4], Option)], index: usize) -> [u8; 4] { let color = stops[index].0; if color[3] != 0 { return color; @@ -5372,7 +5876,12 @@ fn gradient_stop_color( let neighbor = stops[index + 1..] .iter() .find(|(candidate, _)| candidate[3] != 0) - .or_else(|| stops[..index].iter().rev().find(|(candidate, _)| candidate[3] != 0)); + .or_else(|| { + stops[..index] + .iter() + .rev() + .find(|(candidate, _)| candidate[3] != 0) + }); neighbor .map(|(neighbor, _)| [neighbor[0], neighbor[1], neighbor[2], 0]) .unwrap_or(color) @@ -5629,6 +6138,7 @@ fn paint_in_flow_generated_box( root_font_size: f32, base_url: Option<&str>, image_cache: &mut RenderResourceCache, + raster_scale: f32, ) { let Some(host_style) = laid.styles.get(&generated.host) else { return; @@ -5649,7 +6159,10 @@ fn paint_in_flow_generated_box( width: generated.rect.width, height: generated.rect.height, }; - let clip = scroll_state.descendant_clip_for(laid, generated.host); + let overflow_clip = scroll_state.descendant_overflow_clip_for(laid, generated.host); + let clip = overflow_clip + .as_ref() + .map(|clip| clip.viewport_rect(scroll_state.surface_extent.unwrap_or(viewport))); let visible = match clip { Some(clip) => rect.intersect(&clip), None => Some(rect), @@ -5658,9 +6171,23 @@ fn paint_in_flow_generated_box( if visible.width <= 0.0 || visible.height <= 0.0 { return; } + let ancestor_clip_mask = overflow_clip.as_ref().and_then(|clip| { + overflow_clip_mask( + pixmap.width(), + pixmap.height(), + clip, + scroll_state.surface_extent.unwrap_or(viewport), + ) + }); if let Some(shadow) = style.box_shadow { - paint_box_shadow(pixmap, &shadow, &rect, style.border_model.radii, clip); + paint_box_shadow( + pixmap, + &shadow, + &rect, + style.border_model.radii, + ancestor_clip_mask.as_ref(), + ); } let radius = style.border_model.radii.resolve(rect.width, rect.height); let clip_path_mask = style.clip_path.as_ref().and_then(|polygon| { @@ -5676,7 +6203,9 @@ fn paint_in_flow_generated_box( }); let background = background_geometry(&rect, style); let background_path = background_clip_path(background); - let background_mask = background_extra_clip(pixmap, clip, clip_path_mask.as_ref()); + let element_clip_mask = + background_extra_clip(ancestor_clip_mask.as_ref(), clip_path_mask.as_ref()); + let background_mask = element_clip_mask.clone(); if style.mask_image.is_none() && !style.background_clip_text { if let (Some(color), Some(path)) = (style.background_color, background_path.as_ref()) { let mut paint = Paint::default(); @@ -5686,7 +6215,7 @@ fn paint_in_flow_generated_box( path, &paint, FillRule::Winding, - Transform::identity(), + raster_transform(raster_scale), background_mask.as_ref(), ); } @@ -5705,31 +6234,31 @@ fn paint_in_flow_generated_box( ); } } else { - if let Some((center, stops)) = &style.background_radial_gradient { - if let Some(path) = background_path.as_ref() { - paint_radial_gradient( + if let Some((center, stops)) = &style.background_radial_gradient { + if let Some(path) = background_path.as_ref() { + paint_radial_gradient( + pixmap, + path, + &background.origin_rect, + *center, + stops, + background_mask.as_ref(), + ); + } + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient_sampled( pixmap, - path, + &background.clip_rect, &background.origin_rect, + background.clip_radii, + *angle, *center, stops, background_mask.as_ref(), ); } - } - if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient_sampled( - pixmap, - &background.clip_rect, - &background.origin_rect, - background.clip_radii, - *angle, - *center, - stops, - background_mask.as_ref(), - ); - } - if let Some((angle, stops)) = &style.background_gradient { + if let Some((angle, stops)) = &style.background_gradient { if let Some(path) = background_path.as_ref() { paint_linear_gradient( pixmap, @@ -5759,7 +6288,7 @@ fn paint_in_flow_generated_box( style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, - clip_path_mask.as_ref(), + element_clip_mask.as_ref(), pixmap, image_cache, ); @@ -5792,8 +6321,20 @@ fn paint_in_flow_generated_box( } } - paint_css_border(pixmap, &rect, style, clip_path_mask.as_ref()); - paint_css_outline(pixmap, &rect, style, clip_path_mask.as_ref()); + paint_css_border( + pixmap, + &rect, + style, + element_clip_mask.as_ref(), + raster_scale, + ); + paint_css_outline( + pixmap, + &rect, + style, + element_clip_mask.as_ref(), + raster_scale, + ); } fn paint_positioned_pseudo( @@ -5805,8 +6346,10 @@ fn paint_positioned_pseudo( viewport: (f32, f32), root_font_size: f32, ancestor_clip: Option, + ancestor_clip_mask: Option<&tiny_skia::Mask>, base_url: Option<&str>, image_cache: &mut RenderResourceCache, + raster_scale: f32, ) { if style.position != Some(taffy::Position::Absolute) { return; @@ -5818,9 +6361,9 @@ fn paint_positioned_pseudo( viewport.0 / 100.0, viewport.1 / 100.0, ) { - crate::Dimension::Px(value) => Some(value), - crate::Dimension::Percent(value) => Some(value * basis), - _ => None, + crate::Dimension::Px(value) => Some(value), + crate::Dimension::Percent(value) => Some(value * basis), + _ => None, }; let top = style.inset[0].and_then(|value| resolve(value, containing_block.height)); let right = style.inset[1].and_then(|value| resolve(value, containing_block.width)); @@ -5883,8 +6426,8 @@ fn paint_positioned_pseudo( }); let background = background_geometry(&rect, style); let background_path = background_clip_path(background); - let background_mask = - background_extra_clip(pixmap, ancestor_clip, clip_path_mask.as_ref()); + let element_clip_mask = background_extra_clip(ancestor_clip_mask, clip_path_mask.as_ref()); + let background_mask = element_clip_mask.clone(); if style.mask_image.is_none() && !style.background_clip_text { if let (Some(color), Some(path)) = (style.background_color, background_path.as_ref()) { let mut paint = Paint::default(); @@ -5893,7 +6436,7 @@ fn paint_positioned_pseudo( path, &paint, FillRule::Winding, - Transform::identity(), + raster_transform(raster_scale), background_mask.as_ref(), ); } @@ -5912,31 +6455,31 @@ fn paint_positioned_pseudo( ); } } else { - if let Some((center, stops)) = &style.background_radial_gradient { - if let Some(path) = background_path.as_ref() { - paint_radial_gradient( + if let Some((center, stops)) = &style.background_radial_gradient { + if let Some(path) = background_path.as_ref() { + paint_radial_gradient( + pixmap, + path, + &background.origin_rect, + *center, + stops, + background_mask.as_ref(), + ); + } + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient_sampled( pixmap, - path, + &background.clip_rect, &background.origin_rect, + background.clip_radii, + *angle, *center, stops, background_mask.as_ref(), ); } - } - if let Some((angle, center, stops)) = &style.background_conic_gradient { - paint_conic_gradient_sampled( - pixmap, - &background.clip_rect, - &background.origin_rect, - background.clip_radii, - *angle, - *center, - stops, - background_mask.as_ref(), - ); - } - if let Some((angle, stops)) = &style.background_gradient { + if let Some((angle, stops)) = &style.background_gradient { if let Some(path) = background_path.as_ref() { paint_linear_gradient( pixmap, @@ -5966,7 +6509,7 @@ fn paint_positioned_pseudo( style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, - clip_path_mask.as_ref(), + element_clip_mask.as_ref(), pixmap, image_cache, ); @@ -5998,8 +6541,20 @@ fn paint_positioned_pseudo( ); } } - paint_css_border(pixmap, &rect, style, clip_path_mask.as_ref()); - paint_css_outline(pixmap, &rect, style, clip_path_mask.as_ref()); + paint_css_border( + pixmap, + &rect, + style, + element_clip_mask.as_ref(), + raster_scale, + ); + paint_css_outline( + pixmap, + &rect, + style, + element_clip_mask.as_ref(), + raster_scale, + ); if let Some(item) = generated_item { let (text_width, text_height) = generated_intrinsic.unwrap_or_default(); // `text-align` positions inline content within the pseudo's content @@ -6010,9 +6565,7 @@ fn paint_positioned_pseudo( // was `right`. let x = if style.display == crate::Display::Flex { match style.justify_content { - Some(taffy::JustifyContent::CENTER) => { - rect.x + (rect.width - text_width) / 2.0 - } + Some(taffy::JustifyContent::CENTER) => rect.x + (rect.width - text_width) / 2.0, Some(taffy::JustifyContent::FLEX_END | taffy::JustifyContent::END) => { rect.x + rect.width - style.padding.right - text_width } @@ -6035,7 +6588,14 @@ fn paint_positioned_pseudo( _ => rect.y + style.padding.top, }; text_engine.finalize(item, (x, y), text_width, Some(visible)); - text_engine.paint_item(item, pixmap, (0.0, 0.0)); + text_engine.paint_item_with_clip_mask_scaled( + item, + pixmap, + (0.0, 0.0), + Some(visible), + element_clip_mask.as_ref(), + raster_scale, + ); } } @@ -6054,11 +6614,19 @@ fn collect_image_intrinsics( let mut out = std::collections::HashMap::new(); let mut selected = HashMap::new(); for nid in tree.descendants(tree.document()) { - let Some(node) = tree.get_node(nid) else { continue }; - if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) { + let Some(node) = tree.get_node(nid) else { + continue; + }; + if node + .as_element() + .map(|e| e.local.as_ref() != "img") + .unwrap_or(true) + { continue; } - let Some((url, density)) = resolve_img_url(tree, nid, viewport) else { continue }; + let Some((url, density)) = resolve_img_url(tree, nid, viewport) else { + continue; + }; let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url); selected.insert( nid, @@ -6067,8 +6635,11 @@ fn collect_image_intrinsics( density, }, ); - let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else { continue }; - let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) + let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else { + continue; + }; + let dimensions = image_dimensions(&bytes) + .map(|(width, height)| (width as f32, height as f32)) .or_else(|| svg_intrinsic(&bytes)); if let Some((w, h)) = dimensions { if w > 0.0 && h > 0.0 { @@ -6092,8 +6663,12 @@ fn collect_content_image_intrinsics( cache: &mut RenderResourceCache, out: &mut std::collections::HashMap, selected: &mut HashMap, + source_intrinsic: &HashMap, + source_selected: &HashMap, + seeded: &HashSet, ) -> bool { let mut changed = false; + let mut active = HashSet::new(); for (&nid, style) in styles { let Some(node) = tree.get_node(nid) else { continue; @@ -6107,8 +6682,12 @@ fn collect_content_image_intrinsics( let Some(url) = style.content_image.as_deref() else { continue; }; - let resolved_url = - resolve_resource_url(url, base_url).unwrap_or_else(|| url.to_string()); + active.insert(nid); + let resolved_url = resolve_resource_url(url, base_url).unwrap_or_else(|| url.to_string()); + let remembered_url_changed = cache + .content_image_intrinsics + .get(&nid) + .is_some_and(|remembered| remembered.resolved_url != resolved_url); selected.insert( nid, SelectedImage { @@ -6116,19 +6695,60 @@ fn collect_content_image_intrinsics( density: 1.0, }, ); + // CSS content replaces the element's ordinary source. Remove the src + // intrinsic before inspecting content so a failed content image cannot + // accidentally retain src geometry. + let previous_dimensions = out.remove(&nid); let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else { + changed |= previous_dimensions.is_some() || remembered_url_changed; + cache.forget_content_image_intrinsic(nid); continue; }; let dimensions = image_dimensions(&bytes) .map(|(width, height)| (width as f32, height as f32)) .or_else(|| svg_intrinsic(&bytes)); let Some((width, height)) = dimensions else { + changed |= previous_dimensions.is_some() || remembered_url_changed; + cache.forget_content_image_intrinsic(nid); continue; }; if width <= 0.0 || height <= 0.0 { + changed |= previous_dimensions.is_some() || remembered_url_changed; + cache.forget_content_image_intrinsic(nid); continue; } - changed |= out.insert(nid, (width, height)) != Some((width, height)); + let dimensions = (width, height); + changed |= previous_dimensions != Some(dimensions) || remembered_url_changed; + out.insert(nid, dimensions); + cache.remember_content_image_intrinsic(nid, resolved_url, dimensions); + } + + // A remembered selection whose computed content disappeared must stop + // overriding the element's ordinary source. Even equal fallback dimensions + // get one correction pass: the first layout consumed a stale CSS selection, + // and URL/selection state is part of the prepared result too. + for nid in seeded.iter().copied().collect::>() { + if active.contains(&nid) { + continue; + } + cache.forget_content_image_intrinsic(nid); + match source_intrinsic.get(&nid).copied() { + Some(dimensions) => { + out.insert(nid, dimensions); + } + None => { + out.remove(&nid); + } + } + match source_selected.get(&nid).cloned() { + Some(source) => { + selected.insert(nid, source); + } + None => { + selected.remove(&nid); + } + } + changed = true; } changed } @@ -6189,11 +6809,19 @@ fn picture_source_url( if cid == img_nid { break; } - let Some(child) = tree.get_node(cid) else { continue }; - if child.as_element().map(|e| e.local.as_ref() != "source").unwrap_or(true) { + let Some(child) = tree.get_node(cid) else { + continue; + }; + if child + .as_element() + .map(|e| e.local.as_ref() != "source") + .unwrap_or(true) + { continue; } - let Some(srcset) = child.get_attribute("srcset") else { continue }; + let Some(srcset) = child.get_attribute("srcset") else { + continue; + }; if srcset.trim().is_empty() { continue; } @@ -6203,9 +6831,7 @@ fn picture_source_url( } } if let Some(m) = child.get_attribute("media") { - if !m.trim().is_empty() - && !crate::css::media_query_applies_for_viewport(m, viewport) - { + if !m.trim().is_empty() && !crate::css::media_query_applies_for_viewport(m, viewport) { continue; } } @@ -6263,7 +6889,11 @@ fn best_srcset_candidate( let density = if desc.is_empty() { 1.0 } else if let Some(w) = desc.strip_suffix('w').and_then(|s| s.parse::().ok()) { - if source_size > 0.0 { w / source_size } else { continue } + if source_size > 0.0 { + w / source_size + } else { + continue; + } } else if let Some(x) = desc.strip_suffix('x').and_then(|s| s.parse::().ok()) { x } else { @@ -6292,7 +6922,9 @@ fn best_srcset_candidate( /// desktop viewport (a bare entry always holds), else the viewport width. Used /// only to convert `w` descriptors to densities, so a coarse value is fine. fn source_size_px(sizes: Option<&str>, viewport: (f32, f32)) -> f32 { - let Some(sizes) = sizes else { return viewport.0 }; + let Some(sizes) = sizes else { + return viewport.0; + }; for entry in sizes.split(',') { let entry = entry.trim(); if entry.is_empty() { @@ -6321,10 +6953,18 @@ fn split_size_entry(entry: &str) -> (Option, String) { let mut depth = 0i32; for c in entry.chars() { match c { - '(' => { depth += 1; cur.push(c); } - ')' => { depth -= 1; cur.push(c); } + '(' => { + depth += 1; + cur.push(c); + } + ')' => { + depth -= 1; + cur.push(c); + } c if c.is_whitespace() && depth == 0 => { - if !cur.is_empty() { tokens.push(std::mem::take(&mut cur)); } + if !cur.is_empty() { + tokens.push(std::mem::take(&mut cur)); + } } c => cur.push(c), } @@ -6333,7 +6973,11 @@ fn split_size_entry(entry: &str) -> (Option, String) { tokens.push(cur); } let len = tokens.pop().unwrap_or_default(); - let cond = if tokens.is_empty() { None } else { Some(tokens.join(" ")) }; + let cond = if tokens.is_empty() { + None + } else { + Some(tokens.join(" ")) + }; (cond, len) } @@ -6343,11 +6987,21 @@ fn split_size_entry(entry: &str) -> (Option, String) { fn length_to_px(len: &str, viewport_width: f32) -> Option { let t = len.trim().to_ascii_lowercase(); let num = |s: &str| s.trim().parse::().ok(); - if let Some(v) = t.strip_suffix("vw").and_then(num) { return Some(v / 100.0 * viewport_width); } - if let Some(v) = t.strip_suffix('%').and_then(num) { return Some(v / 100.0 * viewport_width); } - if let Some(v) = t.strip_suffix("px").and_then(num) { return Some(v); } - if let Some(v) = t.strip_suffix("rem").and_then(num) { return Some(v * 16.0); } - if let Some(v) = t.strip_suffix("em").and_then(num) { return Some(v * 16.0); } + if let Some(v) = t.strip_suffix("vw").and_then(num) { + return Some(v / 100.0 * viewport_width); + } + if let Some(v) = t.strip_suffix('%').and_then(num) { + return Some(v / 100.0 * viewport_width); + } + if let Some(v) = t.strip_suffix("px").and_then(num) { + return Some(v); + } + if let Some(v) = t.strip_suffix("rem").and_then(num) { + return Some(v * 16.0); + } + if let Some(v) = t.strip_suffix("em").and_then(num) { + return Some(v * 16.0); + } num(&t) } @@ -6366,7 +7020,9 @@ fn paint_image( if rect.width <= 0.0 || rect.height <= 0.0 { return false; } - let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false }; + let Some(bytes) = fetch_bytes(src, base_url, cache) else { + return false; + }; let svg = is_svg(&bytes); // Destination sub-rect within the element box. `Fill` keeps the historical @@ -6387,7 +7043,10 @@ fn paint_image( } }; - let (dw, dh) = (dest.width.round().max(1.0) as u32, dest.height.round().max(1.0) as u32); + let (dw, dh) = ( + dest.width.round().max(1.0) as u32, + dest.height.round().max(1.0) as u32, + ); let content = if svg { render_svg(&bytes, dw, dh) } else { @@ -6429,12 +7088,9 @@ fn paint_image( }) }; match (clip.as_mut(), path) { - (Some(mask), Some(path)) => mask.intersect_path( - &path, - FillRule::Winding, - true, - Transform::identity(), - ), + (Some(mask), Some(path)) => { + mask.intersect_path(&path, FillRule::Winding, true, Transform::identity()) + } (None, _) => { clip = rounded_box_clip_mask_radii( pixmap.width(), @@ -6452,14 +7108,16 @@ fn paint_image( content.as_ref(), &tiny_skia::PixmapPaint::default(), transform - .map(|transform| Transform::from_row( - transform.a, - transform.b, - transform.c, - transform.d, - transform.e, - transform.f, - )) + .map(|transform| { + Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ) + }) .unwrap_or_else(Transform::identity), clip.as_ref(), ); @@ -6527,6 +7185,52 @@ fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option Option { + let bounds = clip.viewport_rect(viewport); + let mut mask = box_clip_mask(pw, ph, &bounds)?; + let offset = clip.rounded_offset(); + let mut current = clip.rounded_chain().map(AsRef::as_ref); + while let Some(node) = current { + let rect = crate::Rect { + x: node.clip.rect.x + offset.0, + y: node.clip.rect.y + offset.1, + ..node.clip.rect + }; + if let Some(path) = + rounded_rect_path_radii(rect.x, rect.y, rect.width, rect.height, node.clip.radii) + { + mask.intersect_path(&path, FillRule::Winding, true, Transform::identity()); + } + current = node.parent.as_deref(); + } + Some(mask) +} + +fn intersect_clip_masks( + mut first: Option, + second: Option<&tiny_skia::Mask>, +) -> Option { + match (first.as_mut(), second) { + (Some(first_mask), Some(second)) => { + for (a, b) in first_mask.data_mut().iter_mut().zip(second.data()) { + *a = ((*a as u16 * *b as u16 + 127) / 255) as u8; + } + first + } + (None, Some(second)) => Some(second.clone()), + _ => first, + } +} + fn rounded_box_clip_mask_radii( pw: u32, ph: u32, @@ -6537,13 +7241,7 @@ fn rounded_box_clip_mask_radii( return box_clip_mask(pw, ph, rect); } let mut mask = tiny_skia::Mask::new(pw, ph)?; - let path = rounded_rect_path_radii( - rect.x, - rect.y, - rect.width, - rect.height, - radii, - )?; + let path = rounded_rect_path_radii(rect.x, rect.y, rect.width, rect.height, radii)?; mask.fill_path(&path, FillRule::Winding, true, Transform::identity()); Some(mask) } @@ -6562,14 +7260,14 @@ fn polygon_clip_mask( viewport: (f32, f32), ) -> Option { let resolve = |coordinate: crate::Dimension, basis: f32| match coordinate.resolve( - em, - rem, - viewport.0 / 100.0, - viewport.1 / 100.0, - ) { - crate::Dimension::Px(value) => Some(value), - crate::Dimension::Percent(value) => Some(value * basis), - _ => None, + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + ) { + crate::Dimension::Px(value) => Some(value), + crate::Dimension::Percent(value) => Some(value * basis), + _ => None, }; let mut builder = PathBuilder::new(); for (index, &(x, y)) in polygon.points.iter().enumerate() { @@ -6642,7 +7340,8 @@ fn render_svg_with_font_database( return None; } let mut svg_pixmap = Pixmap::new(width, height)?; - let transform = Transform::from_scale(width as f32 / size.width(), height as f32 / size.height()); + let transform = + Transform::from_scale(width as f32 / size.width(), height as f32 / size.height()); resvg::render(&tree, transform, &mut svg_pixmap.as_mut()); Some(svg_pixmap) } @@ -6775,26 +7474,26 @@ fn svg_root_attr_value_range(tag: &str, wanted: &str) -> Option<(usize, usize)> } let (value_start, value_end) = if index < bytes.len() && matches!(bytes[index], b'"' | b'\'') { - let delimiter = bytes[index]; - index += 1; - let start = index; - while index < bytes.len() && bytes[index] != delimiter { + let delimiter = bytes[index]; index += 1; - } - let end = index; - index = (index + 1).min(bytes.len()); - (start, end) - } else { - let start = index; - while index < bytes.len() - && !bytes[index].is_ascii_whitespace() - && bytes[index] != b'>' - && bytes[index] != b'/' - { - index += 1; - } - (start, index) - }; + let start = index; + while index < bytes.len() && bytes[index] != delimiter { + index += 1; + } + let end = index; + index = (index + 1).min(bytes.len()); + (start, end) + } else { + let start = index; + while index < bytes.len() + && !bytes[index].is_ascii_whitespace() + && bytes[index] != b'>' + && bytes[index] != b'/' + { + index += 1; + } + (start, index) + }; if &tag[name_start..name_end] == wanted { return Some((value_start, value_end)); } @@ -6841,8 +7540,12 @@ fn serialize_svg_styled( } fn inject_svg_current_color(markup: &mut String, color: [u8; 4]) { - let Some(start) = markup.find("').map(|offset| start + offset) else { return }; + let Some(start) = markup.find("').map(|offset| start + offset) else { + return; + }; let root = &markup[start..end]; // An explicit presentation attribute already survives serialization and // is the correct local currentColor source. @@ -6877,14 +7580,7 @@ fn serialize_svg_node( // Document/comment/PI: no tag of its own, emit only element children. None => { for child in tree.children(nid) { - serialize_svg_node( - tree, - child, - false, - styles, - suppress_opacity_for, - buf, - ); + serialize_svg_node(tree, child, false, styles, suppress_opacity_for, buf); } return; } @@ -7006,14 +7702,7 @@ fn serialize_svg_node( } buf.push('>'); for child in tree.children(nid) { - serialize_svg_node( - tree, - child, - false, - styles, - suppress_opacity_for, - buf, - ); + serialize_svg_node(tree, child, false, styles, suppress_opacity_for, buf); } buf.push_str(" = Vec::new(); let mut local_fragments = Vec::new(); for nid in tree.descendants(root) { - let Some(node) = tree.get_node(nid) else { continue }; - let Some(el) = node.as_element() else { continue }; + let Some(node) = tree.get_node(nid) else { + continue; + }; + let Some(el) = node.as_element() else { + continue; + }; if el.local.as_ref() != "use" { continue; } @@ -7098,15 +7791,21 @@ fn inject_external_sprites( let mut local_nodes = std::collections::HashMap::new(); if !wanted_local.is_empty() { for nid in tree.descendants(tree.document()) { - let Some(node) = tree.get_node(nid) else { continue }; - let Some(id) = node.get_attribute("id") else { continue }; + let Some(node) = tree.get_node(nid) else { + continue; + }; + let Some(id) = node.get_attribute("id") else { + continue; + }; if wanted_local.contains(id) { local_nodes.entry(id.to_string()).or_insert(nid); } } } for frag in local_fragments { - let Some(&symbol_id) = local_nodes.get(&frag) else { continue }; + let Some(&symbol_id) = local_nodes.get(&frag) else { + continue; + }; if symbol_id == root || root_descendants.contains(&symbol_id) { continue; } @@ -7335,15 +8034,12 @@ fn paint_mask( if rect.width <= 0.0 || rect.height <= 0.0 { return false; } - let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false }; + let Some(bytes) = fetch_bytes(src, base_url, cache) else { + return false; + }; let (box_width, box_height) = (rect.width.ceil() as u32, rect.height.ceil() as u32); let (tile_width, tile_height) = mask_size - .map(|(width, height)| { - ( - width.max(1.0).ceil() as u32, - height.max(1.0).ceil() as u32, - ) - }) + .map(|(width, height)| (width.max(1.0).ceil() as u32, height.max(1.0).ceil() as u32)) .unwrap_or((box_width, box_height)); let mask = if is_svg(&bytes) { render_svg(&bytes, tile_width, tile_height) @@ -7357,12 +8053,9 @@ fn paint_mask( } else { mask_repeat.unwrap_or((false, false)) }; - let normalized_linear = - linear_gradient.map(|(_, stops)| normalized_stops(stops)); - let normalized_conic = - conic_gradient.map(|(_, _, stops)| normalized_stops(stops)); - let normalized_radial = - radial_gradient.map(|(_, stops)| normalized_stops(stops)); + let normalized_linear = linear_gradient.map(|(_, stops)| normalized_stops(stops)); + let normalized_conic = conic_gradient.map(|(_, _, stops)| normalized_stops(stops)); + let normalized_radial = radial_gradient.map(|(_, stops)| normalized_stops(stops)); let Some(mut recolored) = Pixmap::new(box_width, box_height) else { return false; }; @@ -7376,8 +8069,7 @@ fn paint_mask( continue; } let tile_x = if repeat.0 { x % tile_width } else { x }; - let coverage = - mask.pixels()[(tile_y * tile_width + tile_x) as usize].alpha() as u32; + let coverage = mask.pixels()[(tile_y * tile_width + tile_x) as usize].alpha() as u32; if coverage == 0 { continue; } @@ -7399,26 +8091,16 @@ fn paint_mask( fill }; color[3] = ((color[3] as u32 * coverage) / 255) as u8; - recolored.pixels_mut()[(y * box_width + x) as usize] = - premultiplied(color); + recolored.pixels_mut()[(y * box_width + x) as usize] = premultiplied(color); } } let mut clip = extra_clip.cloned(); if !border_radius.is_zero() { - let path = rounded_rect_path_radii( - rect.x, - rect.y, - rect.width, - rect.height, - border_radius, - ); + let path = rounded_rect_path_radii(rect.x, rect.y, rect.width, rect.height, border_radius); match (clip.as_mut(), path) { - (Some(mask), Some(path)) => mask.intersect_path( - &path, - FillRule::Winding, - true, - Transform::identity(), - ), + (Some(mask), Some(path)) => { + mask.intersect_path(&path, FillRule::Winding, true, Transform::identity()) + } (None, _) => { clip = rounded_box_clip_mask_radii( pixmap.width(), @@ -7444,6 +8126,7 @@ fn paint_mask( #[cfg(test)] mod tests { use super::*; + use crate::dom::layout_dom_with_web_fonts; use obscura_dom::tree_sink::parse_html; #[test] @@ -7577,10 +8260,9 @@ mod tests { // enters its preload range. It is still a successfully decoded image: // Chromium reports complete=true and naturalWidth=1. const TRANSPARENT_GIF: &[u8] = &[ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x70, - 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, - 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, - 0x44, 0x01, 0x00, 0x3b, + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x70, 0x00, 0x00, 0x21, + 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, ]; // Apple's deliberately palette-less placeholder is accepted by @@ -7589,10 +8271,9 @@ mod tests { assert_eq!(image_dimensions(TRANSPARENT_GIF), Some((1, 1))); const VISIBLE_GIF: &[u8] = &[ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, - 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x01, 0x4c, 0x00, - 0x3b, + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x02, 0x01, 0x4c, 0x00, 0x3b, ]; let decoded = raster_to_pixmap(VISIBLE_GIF, 1, 1).expect("decode GIF"); assert_eq!(decoded.width(), 1); @@ -7611,10 +8292,9 @@ mod tests { "#, ); - let top = paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 0.0)) - .expect("top viewport"); - let scrolled = paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 80.0)) - .expect("scrolled viewport"); + let top = paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 0.0)).expect("top viewport"); + let scrolled = + paint_dom_scrolled(&tree, (100.0, 80.0), None, (0.0, 80.0)).expect("scrolled viewport"); let top_content = top.pixel(50, 10).expect("top content"); assert!( @@ -7658,13 +8338,16 @@ mod tests { let top = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)).expect("top capture"); let scrolled = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 1000.0)) - .expect("scrolled capture"); + .expect("scrolled capture"); let top_repeat = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) - .expect("repeated top capture"); + .expect("repeated top capture"); let scrolled_repeat = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 1000.0)) - .expect("repeated scrolled capture"); + .expect("repeated scrolled capture"); - assert_eq!(top, top_repeat, "returning to the top must not accumulate scroll"); + assert_eq!( + top, top_repeat, + "returning to the top must not accumulate scroll" + ); assert_eq!( scrolled, scrolled_repeat, "repeated bottom paint must reuse immutable document geometry" @@ -7800,13 +8483,13 @@ mod tests { let base_clips = prepared.layout().clip_rects.clone(); let near = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 20.0)) - .expect("near capture"); + .expect("near capture"); let far = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 100.0)) - .expect("far capture"); + .expect("far capture"); let far_repeat = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 100.0)) - .expect("repeated far capture"); + .expect("repeated far capture"); let near_after_far = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 20.0)) - .expect("repeated near capture"); + .expect("repeated near capture"); assert_ne!( near, far, @@ -7857,10 +8540,8 @@ mod tests { let viewport = (800.0, 513.0); let top = paint_dom_scrolled(&tree, viewport, None, (0.0, 0.0)).unwrap(); let stuck = paint_dom_scrolled(&tree, viewport, None, (0.0, 100.0)).unwrap(); - let bottom_normal = - paint_dom_scrolled(&tree, viewport, None, (0.0, 400.0)).unwrap(); - let boundary = - paint_dom_scrolled(&tree, viewport, None, (0.0, 9999.0)).unwrap(); + let bottom_normal = paint_dom_scrolled(&tree, viewport, None, (0.0, 400.0)).unwrap(); + let boundary = paint_dom_scrolled(&tree, viewport, None, (0.0, 9999.0)).unwrap(); let is_color = |pixel: tiny_skia::PremultipliedColorU8, rgb: [u8; 3]| { (pixel.red() as i16 - rgb[0] as i16).abs() < 8 @@ -7886,16 +8567,37 @@ mod tests { fn object_fit_contain_and_cover_center_and_preserve_aspect() { // A 200x100 box (2:1) with a square 100x100 image, offset so centering // is checked against the box origin, not (0,0). - let box_rect = crate::Rect { x: 10.0, y: 20.0, width: 200.0, height: 100.0 }; + let box_rect = crate::Rect { + x: 10.0, + y: 20.0, + width: 200.0, + height: 100.0, + }; let (iw, ih) = (100.0f32, 100.0f32); // Contain: the largest square fitting inside 200x100 is 100x100, // letterboxed horizontally and centered. let c = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Contain); - assert!((c.width - 100.0).abs() < 0.01 && (c.height - 100.0).abs() < 0.01, "contain size {:?}", c); - assert!((c.width / c.height - iw / ih).abs() < 1e-3, "contain preserves aspect: {:?}", c); - assert!((c.x - 60.0).abs() < 0.01, "contain centered x (10 + (200-100)/2): {}", c.x); - assert!((c.y - 20.0).abs() < 0.01, "contain centered y (20 + (100-100)/2): {}", c.y); + assert!( + (c.width - 100.0).abs() < 0.01 && (c.height - 100.0).abs() < 0.01, + "contain size {:?}", + c + ); + assert!( + (c.width / c.height - iw / ih).abs() < 1e-3, + "contain preserves aspect: {:?}", + c + ); + assert!( + (c.x - 60.0).abs() < 0.01, + "contain centered x (10 + (200-100)/2): {}", + c.x + ); + assert!( + (c.y - 20.0).abs() < 0.01, + "contain centered y (20 + (100-100)/2): {}", + c.y + ); // Contain always fits inside the box. assert!(c.x >= box_rect.x - 0.01 && c.x + c.width <= box_rect.x + box_rect.width + 0.01); assert!(c.y >= box_rect.y - 0.01 && c.y + c.height <= box_rect.y + box_rect.height + 0.01); @@ -7903,32 +8605,81 @@ mod tests { // Cover: the smallest square covering 200x100 is 200x200, centered so // it overflows the box vertically (the paint path clips it). let v = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Cover); - assert!((v.width - 200.0).abs() < 0.01 && (v.height - 200.0).abs() < 0.01, "cover size {:?}", v); - assert!((v.width / v.height - iw / ih).abs() < 1e-3, "cover preserves aspect: {:?}", v); - assert!((v.x - 10.0).abs() < 0.01, "cover centered x (10 + (200-200)/2): {}", v.x); - assert!((v.y + 30.0).abs() < 0.01, "cover centered y (20 + (100-200)/2 = -30): {}", v.y); + assert!( + (v.width - 200.0).abs() < 0.01 && (v.height - 200.0).abs() < 0.01, + "cover size {:?}", + v + ); + assert!( + (v.width / v.height - iw / ih).abs() < 1e-3, + "cover preserves aspect: {:?}", + v + ); + assert!( + (v.x - 10.0).abs() < 0.01, + "cover centered x (10 + (200-200)/2): {}", + v.x + ); + assert!( + (v.y + 30.0).abs() < 0.01, + "cover centered y (20 + (100-200)/2 = -30): {}", + v.y + ); // Cover fully covers the box on both axes. assert!(v.x <= box_rect.x + 0.01 && v.x + v.width >= box_rect.x + box_rect.width - 0.01); assert!(v.y <= box_rect.y + 0.01 && v.y + v.height >= box_rect.y + box_rect.height - 0.01); // scale-down never upscales: a 100x100 image in a 200x200 box stays // 100x100 (Contain would grow it to 200x200), centered. - let box2 = crate::Rect { x: 0.0, y: 0.0, width: 200.0, height: 200.0 }; + let box2 = crate::Rect { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; let sd = object_fit_dest(&box2, iw, ih, crate::ObjectFit::ScaleDown); - assert!((sd.width - 100.0).abs() < 0.01 && (sd.height - 100.0).abs() < 0.01, "scale-down no upscale: {:?}", sd); - assert!((sd.x - 50.0).abs() < 0.01 && (sd.y - 50.0).abs() < 0.01, "scale-down centered: {:?}", sd); + assert!( + (sd.width - 100.0).abs() < 0.01 && (sd.height - 100.0).abs() < 0.01, + "scale-down no upscale: {:?}", + sd + ); + assert!( + (sd.x - 50.0).abs() < 0.01 && (sd.y - 50.0).abs() < 0.01, + "scale-down centered: {:?}", + sd + ); let cn = object_fit_dest(&box2, iw, ih, crate::ObjectFit::Contain); - assert!((cn.width - 200.0).abs() < 0.01, "contain upscales into the box: {:?}", cn); + assert!( + (cn.width - 200.0).abs() < 0.01, + "contain upscales into the box: {:?}", + cn + ); // None uses the intrinsic size regardless of box, centered. let n = object_fit_dest(&box2, iw, ih, crate::ObjectFit::None); - assert!((n.width - 100.0).abs() < 0.01 && (n.height - 100.0).abs() < 0.01, "none intrinsic size: {:?}", n); - assert!((n.x - 50.0).abs() < 0.01 && (n.y - 50.0).abs() < 0.01, "none centered: {:?}", n); + assert!( + (n.width - 100.0).abs() < 0.01 && (n.height - 100.0).abs() < 0.01, + "none intrinsic size: {:?}", + n + ); + assert!( + (n.x - 50.0).abs() < 0.01 && (n.y - 50.0).abs() < 0.01, + "none centered: {:?}", + n + ); // Fill stretches to exactly the box. let f = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Fill); - assert!((f.width - box_rect.width).abs() < 0.01 && (f.height - box_rect.height).abs() < 0.01, "fill: {:?}", f); - assert!((f.x - box_rect.x).abs() < 0.01 && (f.y - box_rect.y).abs() < 0.01, "fill origin: {:?}", f); + assert!( + (f.width - box_rect.width).abs() < 0.01 && (f.height - box_rect.height).abs() < 0.01, + "fill: {:?}", + f + ); + assert!( + (f.x - box_rect.x).abs() < 0.01 && (f.y - box_rect.y).abs() < 0.01, + "fill origin: {:?}", + f + ); } #[test] @@ -8007,13 +8758,132 @@ mod tests { pixel.blue() > 220 && pixel.red() < 40 }; - assert!(is_green(5, 30), "border-box clip paints beneath transparent border"); - assert!(is_white(115, 30) && is_green(125, 30), "padding-box excludes border"); + assert!( + is_green(5, 30), + "border-box clip paints beneath transparent border" + ); + assert!( + is_white(115, 30) && is_green(125, 30), + "padding-box excludes border" + ); assert!(is_white(225, 30) && is_white(235, 30) && is_green(245, 30)); - assert!(is_white(351, 21) && is_green(370, 40), "content radius must inset to 20px"); - assert!(is_red(60, 120) && is_blue(80, 120), "clip must not rebase gradient line"); - assert!(is_red(220, 120), "ancestor clipping must not resize gradient coordinates"); - assert!(is_white(300, 120) && !is_white(325, 110), "content origin anchors no-repeat tile"); + assert!( + is_white(351, 21) && is_green(370, 40), + "content radius must inset to 20px" + ); + assert!( + is_red(60, 120) && is_blue(80, 120), + "clip must not rebase gradient line" + ); + assert!( + is_red(220, 120), + "ancestor clipping must not resize gradient coordinates" + ); + assert!( + is_white(300, 120) && !is_white(325, 110), + "content origin anchors no-repeat tile" + ); + } + + #[test] + fn rounded_overflow_clips_descendants_at_the_padding_edge() { + let tree = parse_html( + r#" +
    +
    +
    + "#, + ); + let child = tree.get_element_by_id("child").expect("child"); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = prepare_dom(&tree, (130.0, 130.0), None, &mut resources) + .expect("prepared rounded overflow"); + let child_clip = prepared + .layout + .clip_rects + .get(&child) + .and_then(Option::as_ref) + .expect("child clip"); + let rounded = child_clip + .rounded_chain() + .expect("child must retain the owner's rounded clip node"); + assert_eq!( + rounded.clip.rect, + crate::Rect { + x: 20.0, + y: 20.0, + width: 80.0, + height: 80.0 + } + ); + assert_eq!(rounded.clip.radii.top_left, (20.0, 20.0)); + let direct_mask = + overflow_clip_mask(130, 130, child_clip, (130.0, 130.0)).expect("rounded mask"); + assert_eq!(direct_mask.data()[(23 * 130 + 23) as usize], 0); + let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) + .expect("rounded overflow paint"); + let rgb = |x, y| { + let pixel = pixmap.pixel(x, y).expect("pixel"); + (pixel.red(), pixel.green(), pixel.blue()) + }; + + assert_eq!(rgb(23, 23), (0, 0, 0)); + assert_eq!(rgb(40, 25), (255, 0, 0)); + assert_eq!(rgb(50, 50), (255, 0, 0)); + assert_eq!(rgb(15, 50), (0, 0, 0)); + } + + #[test] + fn nested_rounded_overflow_keeps_every_clip_chain_node() { + let tree = parse_html( + r#" +
    +
    +
    +
    +
    + "#, + ); + let child = tree.get_element_by_id("child").expect("child"); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = prepare_dom(&tree, (140.0, 130.0), None, &mut resources) + .expect("prepared nested clips"); + let mut chain = prepared + .layout + .clip_rects + .get(&child) + .and_then(Option::as_ref) + .and_then(crate::dom::OverflowClip::rounded_chain) + .map(AsRef::as_ref); + let mut chain_len = 0; + while let Some(node) = chain { + chain_len += 1; + chain = node.parent.as_deref(); + } + assert_eq!(chain_len, 2, "both rounded owners must survive the chain"); + let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) + .expect("nested clip paint"); + let rgb = |x, y| { + let pixel = pixmap.pixel(x, y).expect("pixel"); + (pixel.red(), pixel.green(), pixel.blue()) + }; + + assert_eq!(rgb(70, 30), (0, 255, 0), "inside both rounded clips"); + assert_eq!( + rgb(43, 13), + (0, 0, 255), + "inner rounded corner must reveal the outer background" + ); + assert_eq!( + rgb(105, 15), + (255, 255, 255), + "outer rounded corner must still constrain an inner-visible point" + ); } #[test] @@ -8058,8 +8928,8 @@ mod tests { let none = tree.get_element_by_id("none").expect("none"); let decorated = tree.get_element_by_id("decorated").expect("decorated"); let mut resources = RenderResourceCache::default(); - let mut prepared = prepare_dom(&tree, (180.0, 180.0), None, &mut resources) - .expect("prepare"); + let mut prepared = + prepare_dom(&tree, (180.0, 180.0), None, &mut resources).expect("prepare"); let none_rect = prepared.document_rect(none).expect("none rect"); assert_eq!((none_rect.width, none_rect.height), (100.0, 50.0)); @@ -8080,22 +8950,35 @@ mod tests { assert_eq!(computed["outline-style"], "dashed"); assert_eq!(computed["outline-offset"], "3px"); - let pixmap = paint_prepared( - &tree, - &mut prepared, - &mut resources, - (0.0, 0.0), - ) - .expect("paint"); + let pixmap = + paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)).expect("paint"); let sample = |x: u32, y: u32| pixmap.pixel(x, y).expect("pixel"); let top = sample((rect.x + rect.width / 2.0) as u32, (rect.y + 2.0) as u32); - let right = sample((rect.x + rect.width - 2.0) as u32, (rect.y + rect.height / 2.0) as u32); - let bottom = sample((rect.x + rect.width / 2.0) as u32, (rect.y + rect.height - 2.0) as u32); + let right = sample( + (rect.x + rect.width - 2.0) as u32, + (rect.y + rect.height / 2.0) as u32, + ); + let bottom = sample( + (rect.x + rect.width / 2.0) as u32, + (rect.y + rect.height - 2.0) as u32, + ); let left = sample((rect.x + 2.0) as u32, (rect.y + rect.height / 2.0) as u32); - assert!(top.red() > 220 && top.green() < 40 && top.blue() < 40, "{top:?}"); - assert!(right.green() > 90 && right.red() < 40 && right.blue() < 40, "{right:?}"); - assert!(bottom.blue() > 220 && bottom.red() < 40 && bottom.green() < 40, "{bottom:?}"); - assert!(left.red() > 90 && left.blue() > 90 && left.green() < 40, "{left:?}"); + assert!( + top.red() > 220 && top.green() < 40 && top.blue() < 40, + "{top:?}" + ); + assert!( + right.green() > 90 && right.red() < 40 && right.blue() < 40, + "{right:?}" + ); + assert!( + bottom.blue() > 220 && bottom.red() < 40 && bottom.green() < 40, + "{bottom:?}" + ); + assert!( + left.red() > 90 && left.blue() > 90 && left.green() < 40, + "{left:?}" + ); let rounded_corner = sample((rect.x + 1.0) as u32, (rect.y + 1.0) as u32); assert!( @@ -8121,7 +9004,10 @@ mod tests { } } } - assert!(outline_pixels > 40, "dashed outline should paint outside layout: {outline_pixels}"); + assert!( + outline_pixels > 40, + "dashed outline should paint outside layout: {outline_pixels}" + ); } #[test] @@ -8267,7 +9153,10 @@ mod tests { let circle_top = pixmap.pixel(20, 1).expect("circle top"); assert!(circle_top.red() > 200 && circle_top.green() < 40 && circle_top.blue() < 40); - assert!(is_white(60, 3), "a rectangular 50% radius must use a 40x20 elliptical corner"); + assert!( + is_white(60, 3), + "a rectangular 50% radius must use a 40x20 elliptical corner" + ); let ellipse_top = pixmap.pixel(90, 1).expect("ellipse top"); let ellipse_left = pixmap.pixel(51, 20).expect("ellipse left"); for pixel in [ellipse_top, ellipse_left] { @@ -8280,7 +9169,10 @@ mod tests { "a 20px radius must remain circular rather than resolving like 50%: {pill_corner:?}" ); - assert!(is_white(231, 1), "a circular replaced image must clip its raster corner"); + assert!( + is_white(231, 1), + "a circular replaced image must clip its raster corner" + ); let image_center = pixmap.pixel(250, 20).expect("image center"); assert!(image_center.red() > 80 && image_center.blue() > 80 && image_center.green() < 40); } @@ -8568,7 +9460,10 @@ mod tests { 1 => inside.green() > 100 && inside.red() < 80 && inside.blue() < 80, _ => inside.blue() > 180 && inside.red() < 80 && inside.green() < 80, }; - assert!(colored, "{name} must paint inside its percentage polygon: {inside:?}"); + assert!( + colored, + "{name} must paint inside its percentage polygon: {inside:?}" + ); let outside = pixmap.pixel(left + 4, 65).expect("outside pixel"); assert_eq!( (outside.red(), outside.green(), outside.blue()), @@ -8688,6 +9583,9 @@ mod tests { &mut cache, &mut intrinsic, &mut selected, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), )); let laid = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]); let image_id = tree @@ -8734,6 +9632,96 @@ mod tests { ); } + #[test] + fn repeated_content_image_prepare_reuses_intrinsic_and_corrects_changes() { + const SOURCE: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='12'%20height='8'%3E%3C/svg%3E"; + const CONTENT_A: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='30'%20height='20'%3E%3C/svg%3E"; + const CONTENT_B: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='50'%20height='10'%3E%3C/svg%3E"; + let make_tree = |content: Option<&str>| { + let content = content + .map(|url| format!("content:url('{url}');")) + .unwrap_or_default(); + parse_html(&format!( + r#""# + )) + }; + let rect_for = |tree: &DomTree, prepared: &PreparedRender| { + let id = tree + .query_selector("#target") + .expect("selector") + .expect("target"); + prepared.layout.rects[&id] + }; + + let mut resources = RenderResourceCache::default(); + let first_tree = make_tree(Some(CONTENT_A)); + let first = + prepare_dom(&first_tree, (100.0, 80.0), None, &mut resources).expect("first prepare"); + let first_rect = rect_for(&first_tree, &first); + assert_eq!((first_rect.width, first_rect.height), (30.0, 20.0)); + assert_eq!(resources.content_image_layout_retries, 1); + + // Stable node + resolved URL + dimensions seed the first layout. The + // retry counter must not move on a repeated prepare. + let repeated = prepare_dom(&first_tree, (100.0, 80.0), None, &mut resources) + .expect("repeated prepare"); + let repeated_rect = rect_for(&first_tree, &repeated); + assert_eq!((repeated_rect.width, repeated_rect.height), (30.0, 20.0)); + assert_eq!(resources.content_image_layout_retries, 1); + + // A changed computed URL initially receives the remembered geometry, + // then must correct it from the newly selected resource. + let changed_tree = make_tree(Some(CONTENT_B)); + let changed = prepare_dom(&changed_tree, (100.0, 80.0), None, &mut resources) + .expect("changed prepare"); + let changed_rect = rect_for(&changed_tree, &changed); + assert_eq!((changed_rect.width, changed_rect.height), (50.0, 10.0)); + assert_eq!(resources.content_image_layout_retries, 2); + + // Removing CSS content restores the ordinary src selection and its + // intrinsic geometry rather than leaking the remembered CSS image. + let removed_tree = make_tree(None); + let removed = prepare_dom(&removed_tree, (100.0, 80.0), None, &mut resources) + .expect("removed prepare"); + let removed_id = removed_tree + .query_selector("#target") + .expect("selector") + .expect("target"); + let removed_rect = removed.layout.rects[&removed_id]; + assert_eq!((removed_rect.width, removed_rect.height), (12.0, 8.0)); + assert_eq!(resources.content_image_layout_retries, 3); + assert!(resources.content_image_intrinsics.is_empty()); + assert_eq!( + removed.selected_images[&removed_id].resolved_url, SOURCE, + "the HTML source must regain painting ownership" + ); + } + + #[test] + fn content_image_intrinsic_memory_is_bounded_and_refreshes_recency() { + let mut resources = RenderResourceCache::with_loader_and_limits(|_url: &str| None, 2, 1024); + let tree = parse_html(r#""#); + let ids = ["#a", "#b", "#c"].map(|selector| { + tree.query_selector(selector) + .expect("selector") + .expect("image") + }); + resources.remember_content_image_intrinsic(ids[0], "a".into(), (1.0, 1.0)); + resources.remember_content_image_intrinsic(ids[1], "b".into(), (2.0, 2.0)); + // Refreshing id 1 makes id 2 the oldest entry. + resources.remember_content_image_intrinsic(ids[0], "a2".into(), (3.0, 3.0)); + resources.remember_content_image_intrinsic(ids[2], "c".into(), (4.0, 4.0)); + + assert_eq!(resources.content_image_intrinsics.len(), 2); + assert!(resources.content_image_intrinsics.contains_key(&ids[0])); + assert!(!resources.content_image_intrinsics.contains_key(&ids[1])); + assert!(resources.content_image_intrinsics.contains_key(&ids[2])); + assert_eq!( + resources.content_image_intrinsics[&ids[0]].resolved_url, + "a2" + ); + } + #[test] fn repeated_data_svg_masks_sample_radial_sources_on_every_box_path() { let tree = parse_html( @@ -8783,31 +9771,43 @@ mod tests { }) .count() }; - let is_radial_color = |red, green, blue| { - red > 70 && blue > 100 && blue as u16 > green as u16 * 2 - }; - for (name, left) in [("ordinary element", 0), ("in-flow pseudo", 100), ("positioned pseudo", 200)] { + let is_radial_color = + |red, green, blue| red > 70 && blue > 100 && blue as u16 > green as u16 * 2; + for (name, left) in [ + ("ordinary element", 0), + ("in-flow pseudo", 100), + ("positioned pseudo", 200), + ] { let colored = count_pixels(left, 0, is_radial_color); assert!( colored > 20, "{name} must sample the radial source through the repeated SVG mask, found {colored} colored pixels" ); - let black = count_pixels(left, 0, |red, green, blue| red < 20 && green < 20 && blue < 20); + let black = count_pixels(left, 0, |red, green, blue| { + red < 20 && green < 20 && blue < 20 + }); assert_eq!( black, 0, "{name} must not fall back to the default black mask fill" ); } assert!( - count_pixels(0, 80, |red, green, blue| green > 100 && red < 40 && blue < 40) > 20, + count_pixels(0, 80, |red, green, blue| green > 100 + && red < 40 + && blue < 40) + > 20, "solid mask sources must keep painting" ); assert!( - count_pixels(100, 80, |red, green, blue| (red > 100 || blue > 100) && green < 80) > 20, + count_pixels(100, 80, |red, green, blue| (red > 100 || blue > 100) + && green < 80) + > 20, "linear-gradient mask sources must keep painting" ); assert!( - count_pixels(200, 80, |red, green, blue| (red > 100 || blue > 100) && green < 80) > 20, + count_pixels(200, 80, |red, green, blue| (red > 100 || blue > 100) + && green < 80) + > 20, "conic-gradient mask sources must keep painting" ); } @@ -9004,7 +10004,10 @@ mod tests { "selected label, border, and disclosure arrow should paint" ); let select = tree.get_element_by_id("theme").unwrap(); - assert_eq!(selected_option_label(&tree, select).as_deref(), Some("Dark")); + assert_eq!( + selected_option_label(&tree, select).as_deref(), + Some("Dark") + ); } #[test] @@ -9279,10 +10282,7 @@ mod tests { let last = fragments[2]; assert_eq!(rgb(first.x + 0.5, first.y + 1.0), (0, 0, 255)); assert_eq!(rgb(middle.x + 0.5, middle.y + 1.0), (255, 0, 0)); - assert_eq!( - rgb(last.x + last.width - 0.5, last.y + 1.0), - (0, 0, 255) - ); + assert_eq!(rgb(last.x + last.width - 0.5, last.y + 1.0), (0, 0, 255)); let gap_y = (first.y + first.height + middle.y) * 0.5; assert_eq!( rgb(5.0, gap_y), @@ -9390,7 +10390,11 @@ mod tests { ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let p = pixmap.pixel(5, 5).expect("pixel"); - assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p); + assert!( + p.blue() > 200, + "expected blue to paint over red, got {:?}", + p + ); } #[test] @@ -9430,7 +10434,10 @@ mod tests { ); // Nothing painted at the pre-transform origin: both boxes moved away. let origin = pixmap.pixel(5, 5).expect("pixel"); - assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255)); + assert_eq!( + (origin.red(), origin.green(), origin.blue()), + (255, 255, 255) + ); } #[test] @@ -9501,21 +10508,42 @@ mod tests { let assert_rect = |actual: crate::Rect, expected: crate::Rect| { assert!((actual.x - expected.x).abs() < 0.02, "x: {actual:?}"); assert!((actual.y - expected.y).abs() < 0.02, "y: {actual:?}"); - assert!((actual.width - expected.width).abs() < 0.02, "width: {actual:?}"); - assert!((actual.height - expected.height).abs() < 0.02, "height: {actual:?}"); + assert!( + (actual.width - expected.width).abs() < 0.02, + "width: {actual:?}" + ); + assert!( + (actual.height - expected.height).abs() < 0.02, + "height: {actual:?}" + ); }; let first = tree.get_element_by_id("translate-rotate").unwrap(); let second = tree.get_element_by_id("rotate-translate").unwrap(); assert_rect( prepared.document_rect(first).unwrap(), - crate::Rect { x: 140.0, y: 50.0, width: 10.0, height: 20.0 }, + crate::Rect { + x: 140.0, + y: 50.0, + width: 10.0, + height: 20.0, + }, ); assert_rect( prepared.document_rect(second).unwrap(), - crate::Rect { x: 40.0, y: 150.0, width: 10.0, height: 20.0 }, + crate::Rect { + x: 40.0, + y: 150.0, + width: 10.0, + height: 20.0, + }, ); let child = tree.get_element_by_id("child").unwrap(); - let child_rect = crate::Rect { x: 30.0, y: 240.0, width: 40.0, height: 20.0 }; + let child_rect = crate::Rect { + x: 30.0, + y: 240.0, + width: 40.0, + height: 20.0, + }; assert_rect(prepared.document_rect(child).unwrap(), child_rect); assert_rect( prepared.viewport_client_rects(child, (0.0, 0.0)).unwrap()[0], @@ -9524,12 +10552,22 @@ mod tests { let fixed = tree.get_element_by_id("captured-fixed").unwrap(); assert_rect( prepared.document_rect(fixed).unwrap(), - crate::Rect { x: 50.0, y: 200.0, width: 20.0, height: 20.0 }, + crate::Rect { + x: 50.0, + y: 200.0, + width: 20.0, + height: 20.0, + }, ); let centered = tree.get_element_by_id("center-origin").unwrap(); assert_rect( prepared.document_rect(centered).unwrap(), - crate::Rect { x: 105.0, y: 395.0, width: 10.0, height: 20.0 }, + crate::Rect { + x: 105.0, + y: 395.0, + width: 10.0, + height: 20.0, + }, ); let cssom = tree.get_element_by_id("cssom-transform").unwrap(); let computed = prepared.computed_style(cssom).unwrap(); @@ -9554,7 +10592,10 @@ mod tests { let inside = pixmap.pixel(55, 40).expect("inside ancestor clip"); assert!(inside.green() > 120 && inside.red() < 40); let outside = pixmap.pixel(65, 40).expect("outside ancestor clip"); - assert_eq!((outside.red(), outside.green(), outside.blue()), (255, 255, 255)); + assert_eq!( + (outside.red(), outside.green(), outside.blue()), + (255, 255, 255) + ); } #[test] @@ -9570,7 +10611,12 @@ mod tests { assert!(prepared.layout().transforms.is_empty()); assert_eq!( prepared.document_rect(plain), - Some(crate::Rect { x: 10.0, y: 12.0, width: 20.0, height: 15.0 }) + Some(crate::Rect { + x: 10.0, + y: 12.0, + width: 20.0, + height: 15.0 + }) ); let pixmap = paint_dom(&tree, (80.0, 60.0), None).unwrap(); let painted = pixmap.pixel(15, 15).unwrap(); @@ -9667,7 +10713,10 @@ mod tests { } } } - assert!(!any_red, "translate(-10000px,0) box should be off-screen and unpainted"); + assert!( + !any_red, + "translate(-10000px,0) box should be off-screen and unpainted" + ); } #[test] @@ -9687,10 +10736,17 @@ mod tests { ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let center = pixmap.pixel(100, 100).expect("pixel"); - assert!(center.red() > 200 && center.blue() < 60, "expected centered red box, got {:?}", center); + assert!( + center.red() > 200 && center.blue() < 60, + "expected centered red box, got {:?}", + center + ); // Just outside the centered box stays white. let outside = pixmap.pixel(70, 70).expect("pixel"); - assert_eq!((outside.red(), outside.green(), outside.blue()), (255, 255, 255)); + assert_eq!( + (outside.red(), outside.green(), outside.blue()), + (255, 255, 255) + ); } #[test] @@ -9708,7 +10764,9 @@ mod tests { break; } } - if found_green { break; } + if found_green { + break; + } } assert!(found_green, "expected green text to be painted"); } @@ -9760,8 +10818,14 @@ mod tests { blue |= b > r.saturating_add(20) && b > g.saturating_add(5); normal |= b > g.saturating_add(10) && r < 80 && g < 100; } - assert!(normal, "surrounding heading text should retain its normal color"); - assert!(green && blue, "inline accent should contain both gradient colors"); + assert!( + normal, + "surrounding heading text should retain its normal color" + ); + assert!( + green && blue, + "inline accent should contain both gradient colors" + ); } #[test] @@ -9774,11 +10838,26 @@ mod tests { let svg = tree.query_selector("svg").unwrap().unwrap(); let out = serialize_svg(&tree, svg); assert!(out.starts_with(""), "path opened + closed: {out}"); + assert!( + out.contains(r#"viewBox="0 0 10 10""#), + "viewBox preserved: {out}" + ); + assert!( + out.contains(r#"xmlns="http://www.w3.org/2000/svg""#), + "xmlns injected: {out}" + ); + assert!( + out.contains(""), + "path opened + closed: {out}" + ); assert!(out.trim_end().ends_with(""), "root closed: {out}"); // The serialized string parses as a standalone SVG document. let opts = usvg::Options::default(); @@ -9839,7 +10918,11 @@ mod tests { ); let svg = tree.query_selector("svg").unwrap().unwrap(); let out = serialize_svg(&tree, svg); - assert_eq!(out.matches("xmlns=").count(), 1, "no duplicate xmlns: {out}"); + assert_eq!( + out.matches("xmlns=").count(), + 1, + "no duplicate xmlns: {out}" + ); } #[test] @@ -9896,7 +10979,10 @@ mod tests { for pixel in pixmap.pixels() { found |= pixel.blue() > 120 && pixel.green() > 80 && pixel.red() < 40; } - assert!(found, "computed color should resolve currentColor in inline svg"); + assert!( + found, + "computed color should resolve currentColor in inline svg" + ); } #[test] @@ -9907,13 +10993,22 @@ mod tests { ); let copy = tree.get_element_by_id("copy").unwrap(); let mut resources = RenderResourceCache::default(); - let prepared = prepare_dom(&tree, (320.0, 200.0), None, &mut resources) - .expect("prepared render"); + let prepared = + prepare_dom(&tree, (320.0, 200.0), None, &mut resources).expect("prepared render"); let computed = prepared.computed_style(copy).expect("computed style"); - assert_eq!(computed.get("overflow-wrap").map(String::as_str), Some("anywhere")); - assert_eq!(computed.get("word-wrap").map(String::as_str), Some("anywhere")); - assert_eq!(computed.get("word-break").map(String::as_str), Some("keep-all")); + assert_eq!( + computed.get("overflow-wrap").map(String::as_str), + Some("anywhere") + ); + assert_eq!( + computed.get("word-wrap").map(String::as_str), + Some("anywhere") + ); + assert_eq!( + computed.get("word-break").map(String::as_str), + Some("keep-all") + ); } #[test] @@ -9953,7 +11048,10 @@ mod tests { } } } - assert!(found_blue, "expected to instantiate the referenced "); + assert!( + found_blue, + "expected to instantiate the referenced " + ); } #[test] @@ -9962,11 +11060,23 @@ mod tests { // referenced verbatim so it can be spliced into the local svg. let sprite = r##""##; let out = extract_svg_element_by_id(sprite, "a").expect("symbol a found"); - assert!(out.starts_with(""), "closed at matching end: {out}"); - assert!(!out.contains(r#"id="b""#), "stops before the sibling symbol: {out}"); + assert!( + out.contains(""), + "closed at matching end: {out}" + ); + assert!( + !out.contains(r#"id="b""#), + "stops before the sibling symbol: {out}" + ); assert!(!out.contains(""#; - assert!(extract_svg_element_by_id(s3, "a").is_none(), "data-id is not id"); + assert!( + extract_svg_element_by_id(s3, "a").is_none(), + "data-id is not id" + ); assert!(extract_svg_element_by_id(s2, "nope").is_none(), "absent id"); } @@ -10083,8 +11196,7 @@ mod tests { && markup.contains("stroke:#000000ff!important"), "serialized presentation colors must use the dark subtree scheme: {markup}" ); - let pixmap = - render_svg(markup.as_bytes(), 10, 10).expect("resolved SVG renders"); + let pixmap = render_svg(markup.as_bytes(), 10, 10).expect("resolved SVG renders"); let center = pixmap.pixel(5, 5).expect("center pixel"); assert!( center.red() > 60 @@ -10161,9 +11273,11 @@ mod tests { let loads = Arc::new(std::sync::Mutex::new(Vec::new())); let loader_loads = Arc::clone(&loads); let mut resources = RenderResourceCache::with_loader(move |url: &str| { - loader_loads.lock().expect("font loads").push(url.to_string()); - (url == "https://example.test/fonts/fixture.ttf") - .then(|| SERIF_FONT_BYTES.to_vec()) + loader_loads + .lock() + .expect("font loads") + .push(url.to_string()); + (url == "https://example.test/fonts/fixture.ttf").then(|| SERIF_FONT_BYTES.to_vec()) }); let prepared = prepare_dom_with_dynamic_fonts( &tree, @@ -10183,7 +11297,10 @@ mod tests { .document_rect(sample) .expect("dynamic geometry") .width; - assert_ne!(fallback, dynamic, "dynamic face must participate in shaping"); + assert_ne!( + fallback, dynamic, + "dynamic face must participate in shaping" + ); assert_eq!( *loads.lock().expect("dynamic font loads"), vec!["https://example.test/fonts/fixture.ttf".to_string()] @@ -10208,8 +11325,8 @@ mod tests { let inner = tree.get_element_by_id("inner").expect("inner"); let target = tree.get_element_by_id("target").expect("target"); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); - let mut prepared = prepare_dom(&tree, (360.0, 240.0), None, &mut resources) - .expect("prepared render"); + let mut prepared = + prepare_dom(&tree, (360.0, 240.0), None, &mut resources).expect("prepared render"); let top_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); let top_outer = prepared .viewport_rect_with_scroll(outer, &top_state) @@ -10240,30 +11357,32 @@ mod tests { let scrolled_target = prepared .viewport_rect_with_scroll(target, &scrolled_state) .expect("scrolled target"); - assert_eq!(scrolled_outer, top_outer, "scroller chrome must not move with its content"); + assert_eq!( + scrolled_outer, top_outer, + "scroller chrome must not move with its content" + ); assert_eq!(scrolled_target.x, top_target.x - 218.0); assert_eq!(scrolled_target.y, top_target.y - 208.0); - let scrolled = paint_prepared_with_scroll( - &tree, - &mut prepared, - &mut resources, - &scrolled_state, - ) - .expect("scrolled paint"); - let repeated = paint_prepared_with_scroll( - &tree, - &mut prepared, - &mut resources, - &scrolled_state, - ) - .expect("repeated paint"); + let scrolled = + paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &scrolled_state) + .expect("scrolled paint"); + let repeated = + paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &scrolled_state) + .expect("repeated paint"); assert_ne!(top.data(), scrolled.data()); - assert_eq!(scrolled.data(), repeated.data(), "scroll deltas must not accumulate"); - let pixel = |pixmap: &Pixmap, x: u32, y: u32| { - pixmap.pixels()[(y * pixmap.width() + x) as usize] - }; - assert_eq!(pixel(&top, 2, 2), pixel(&scrolled, 2, 2), "outer border moved"); + assert_eq!( + scrolled.data(), + repeated.data(), + "scroll deltas must not accumulate" + ); + let pixel = + |pixmap: &Pixmap, x: u32, y: u32| pixmap.pixels()[(y * pixmap.width() + x) as usize]; + assert_eq!( + pixel(&top, 2, 2), + pixel(&scrolled, 2, 2), + "outer border moved" + ); assert_eq!( pixel(&top, 125, 50), pixel(&scrolled, 125, 50), @@ -10361,6 +11480,16 @@ mod tests { let scaled_center = scaled.pixel(10, 10).expect("scaled center"); assert!(scaled_center.red() > 240 && scaled_center.blue() > 240); + let protocol_sized = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::with_output_size(40.0, 150.0, 10.0, 9.0, 1.1, 11, 10), + ) + .expect("protocol-sized region"); + assert_eq!((protocol_sized.width(), protocol_sized.height()), (11, 10)); + assert_eq!(scroll.root_offset(), (0.0, 100.0)); assert_eq!( prepared @@ -10375,8 +11504,8 @@ mod tests { fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() { let tree = parse_html("
    "); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); - let mut prepared = prepare_dom(&tree, (20.0, 20.0), None, &mut resources) - .expect("prepared render"); + let mut prepared = + prepare_dom(&tree, (20.0, 20.0), None, &mut resources).expect("prepared render"); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); for invalid in [ @@ -10407,6 +11536,30 @@ mod tests { .unwrap_err(), CaptureError::AllocationLimitExceeded ); + assert_eq!( + paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 10.0, 10.0, MAX_CAPTURE_SCALE + 1.0), + ) + .unwrap_err(), + CaptureError::AllocationLimitExceeded + ); + assert_eq!( + paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + // Each surface is below the legacy 64M-pixel per-surface + // bound, but their simultaneous RGBA peak exceeds 256 MiB. + CaptureRegion::new(0.0, 0.0, 6000.0, 6000.0, 1.2), + ) + .unwrap_err(), + CaptureError::AllocationLimitExceeded + ); assert_eq!( paint_prepared_region_with_scroll( &tree, @@ -10419,4 +11572,111 @@ mod tests { CaptureError::AllocationLimitExceeded ); } + + #[test] + fn simple_region_capture_rasterizes_geometry_and_text_natively_at_two_x() { + let tree = parse_html( + r#" +
    +

    Hi

    + "#, + ); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = + prepare_dom(&tree, (40.0, 30.0), None, &mut resources).expect("prepared render"); + assert!( + native_raster_scale_supported(&tree, &prepared.layout), + "solid boxes and shaped text should use direct device-scale paint" + ); + let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); + let one_x = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 40.0, 30.0, 1.0), + ) + .unwrap(); + let two_x = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 40.0, 30.0, 2.0), + ) + .unwrap(); + assert_eq!((two_x.width(), two_x.height()), (80, 60)); + + let red = |x, y| { + let pixel = two_x.pixel(x, y).unwrap(); + pixel.red() > 245 && pixel.green() < 10 && pixel.blue() < 10 + }; + assert!(red(15, 7), "the 8 CSS-px box must cover 16 device pixels"); + assert!( + !red(16, 7), + "the adjacent CSS pixel must remain outside the box" + ); + + let source = + image::RgbaImage::from_raw(one_x.width(), one_x.height(), one_x.data().to_vec()) + .unwrap(); + let post_scaled = image::imageops::resize( + &source, + two_x.width(), + two_x.height(), + image::imageops::FilterType::Lanczos3, + ); + let differing = two_x + .data() + .iter() + .zip(post_scaled.as_raw()) + .filter(|(native, post)| native != post) + .count(); + assert!( + differing > 100, + "2x glyph outlines and box edges must be rerasterized, not resize-equivalent ({differing} differing channels)" + ); + let dark_text_pixels = (16..60) + .flat_map(|y| (0..50).map(move |x| (x, y))) + .filter(|&(x, y)| { + let pixel = two_x.pixel(x, y).unwrap(); + pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 + }) + .count(); + assert!(dark_text_pixels > 10, "native 2x text must remain painted"); + } + + #[test] + fn effectful_region_capture_keeps_the_proven_bounded_resample_fallback() { + let tree = parse_html( + r#" +
    + "#, + ); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = + prepare_dom(&tree, (30.0, 30.0), None, &mut resources).expect("prepared render"); + assert!(!native_raster_scale_supported(&tree, &prepared.layout)); + let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); + let one_x = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 30.0, 30.0, 1.0), + ) + .unwrap(); + let two_x = paint_prepared_region_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scroll, + CaptureRegion::new(0.0, 0.0, 30.0, 30.0, 2.0), + ) + .unwrap(); + let source = image::RgbaImage::from_raw(30, 30, one_x.data().to_vec()).unwrap(); + let expected = + image::imageops::resize(&source, 60, 60, image::imageops::FilterType::Lanczos3); + assert_eq!(two_x.data(), expected.as_raw()); + } } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 72d3f01..ce78650 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -36,11 +36,10 @@ pub fn ua_style(tag: &str) -> LayoutStyle { // becoming its own block box (which forces the flex word-promotion // fallback and its fragile one-word-per-line wrapping). Author CSS // (e.g. `code{display:block}`) still overrides this in the cascade. - "span" | "a" | "b" | "i" | "strong" | "em" | "font" | "code" | "small" - | "sub" | "sup" | "mark" | "abbr" | "cite" | "var" | "dfn" | "kbd" - | "samp" | "q" | "time" | "s" | "u" | "del" | "ins" | "tt" | "big" - | "bdi" | "bdo" | "br" | "wbr" | "data" | "output" | "label" | "ruby" | "rt" - | "rp" => Display::Inline, + "span" | "a" | "b" | "i" | "strong" | "em" | "font" | "code" | "small" | "sub" | "sup" + | "mark" | "abbr" | "cite" | "var" | "dfn" | "kbd" | "samp" | "q" | "time" | "s" | "u" + | "del" | "ins" | "tt" | "big" | "bdi" | "bdo" | "br" | "wbr" | "data" | "output" + | "label" | "ruby" | "rt" | "rp" => Display::Inline, "tr" => Display::Flex, _ => Display::Block, }; @@ -51,9 +50,23 @@ pub fn ua_style(tag: &str) -> LayoutStyle { // text-align:center. style.text_align = Some(taffy::AlignItems::CENTER); style.legacy_center = true; - } else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" || tag == "noscript" || tag == "template" - || tag == "desc" || tag == "metadata" || tag == "option" || tag == "optgroup" - || tag == "source" || tag == "track" || tag == "param" || tag == "area" { + } else if tag == "head" + || tag == "script" + || tag == "style" + || tag == "title" + || tag == "meta" + || tag == "link" + || tag == "noscript" + || tag == "template" + || tag == "desc" + || tag == "metadata" + || tag == "option" + || tag == "optgroup" + || tag == "source" + || tag == "track" + || tag == "param" + || tag == "area" + { // `noscript` content is only for scripting-disabled agents; with JS on // (as here) the parser keeps it as raw text and the browser hides it, // so a site's no-JS nav fallback must not paint as literal markup. @@ -71,7 +84,12 @@ pub fn ua_style(tag: &str) -> LayoutStyle { // HTML's UA sheet preserves source whitespace in preformatted blocks. style.white_space = Some(crate::WhiteSpace::Pre); } else if tag == "body" { - style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 }; + style.margin = Edges { + top: 8.0, + right: 8.0, + bottom: 8.0, + left: 8.0, + }; } else if tag == "h1" { style.font_size = None; style.font_size_raw = Some(crate::Dimension::Em(2.0)); @@ -120,7 +138,13 @@ pub fn ua_style(tag: &str) -> LayoutStyle { } } else if tag == "b" || tag == "strong" { style.font_weight = Some("bold".to_string()); - } else if tag == "i" || tag == "em" || tag == "cite" || tag == "var" || tag == "dfn" || tag == "address" { + } else if tag == "i" + || tag == "em" + || tag == "cite" + || tag == "var" + || tag == "dfn" + || tag == "address" + { style.font_style_italic = Some(true); } else if tag == "a" { style.color = Some([0, 0, 238, 255]); // blue @@ -128,7 +152,12 @@ pub fn ua_style(tag: &str) -> LayoutStyle { } else if tag == "iframe" { // HTML's UA sheet gives frames a two-pixel inset border. The paint // and geometry models share the same four-side used state. - style.border = Edges { top: 2.0, right: 2.0, bottom: 2.0, left: 2.0 }; + style.border = Edges { + top: 2.0, + right: 2.0, + bottom: 2.0, + left: 2.0, + }; style.border_model.specified_widths = crate::Sides::all(2.0); style.border_model.styles = crate::Sides::all(crate::BorderStyle::Inset); } else if tag == "button" { @@ -212,11 +241,11 @@ pub fn ua_style(tag: &str) -> LayoutStyle { style.internal_flex_container = true; style.flex_direction = Some(taffy::FlexDirection::Column); style.align_items = Some(taffy::AlignItems::STRETCH); // stretch rows to fill table width - // Rows fill the table width and may shrink below their content's - // min-content size (the flexbox automatic-minimum-size gotcha), so a - // width-constrained taxobox contains its content instead of blowing - // out sideways. (Fully matching CSS auto table layout, where a table - // grows to fit unshrinkable content, needs real table layout.) + // Rows fill the table width and may shrink below their content's + // min-content size (the flexbox automatic-minimum-size gotcha), so a + // width-constrained taxobox contains its content instead of blowing + // out sideways. (Fully matching CSS auto table layout, where a table + // grows to fit unshrinkable content, needs real table layout.) style.min_width = crate::Dimension::Px(0.0); if tag == "table" { // Chromium's HTML UA sheet makes the table grid border-box and @@ -289,7 +318,9 @@ pub(crate) fn partition_declarations(css: &str) -> (String, String) { if decl.is_empty() { continue; } - let Some((name, value)) = decl.split_once(':') else { continue }; + let Some((name, value)) = decl.split_once(':') else { + continue; + }; let mut value = value.trim(); let mut is_important = false; if let Some(bang) = value.rfind('!') { @@ -298,7 +329,11 @@ pub(crate) fn partition_declarations(css: &str) -> (String, String) { is_important = true; } } - let out = if is_important { &mut important } else { &mut normal }; + let out = if is_important { + &mut important + } else { + &mut normal + }; out.push_str(name.trim()); out.push(':'); out.push_str(value); @@ -318,17 +353,16 @@ pub(crate) fn apply_color_scheme_declarations_from( inherited_scheme: bool, ) { for raw in split_declarations(css) { - let Some((name, value)) = raw.trim().split_once(':') else { continue }; + let Some((name, value)) = raw.trim().split_once(':') else { + continue; + }; if name.trim().eq_ignore_ascii_case("color-scheme") { apply_color_scheme(style, value.trim(), inherited_scheme); } } } -pub(crate) fn apply_declarations_with_locked_color_scheme( - style: &mut LayoutStyle, - css: &str, -) { +pub(crate) fn apply_declarations_with_locked_color_scheme(style: &mut LayoutStyle, css: &str) { for raw in split_declarations(css) { let decl = raw.trim(); if decl.is_empty() { @@ -346,7 +380,9 @@ pub(crate) fn apply_declarations_with_locked_color_scheme( pub(crate) fn apply_animation_declarations(style: &mut LayoutStyle, css: &str) { for raw in split_declarations(css) { - let Some((name, value)) = raw.trim().split_once(':') else { continue }; + let Some((name, value)) = raw.trim().split_once(':') else { + continue; + }; let name = name.trim().to_ascii_lowercase(); if name == "animation" || name.starts_with("animation-") { apply_value(style, &name, value.trim()); @@ -357,30 +393,26 @@ pub(crate) fn apply_animation_declarations(style: &mut LayoutStyle, css: &str) { /// Apply one already-cascaded keyframe declaration through the ordinary /// computed-style parser. The animation sampler deliberately reuses this /// path instead of maintaining a second, subtly different value parser. -pub(crate) fn apply_animation_property_value( - style: &mut LayoutStyle, - name: &str, - value: &str, -) { +pub(crate) fn apply_animation_property_value(style: &mut LayoutStyle, name: &str, value: &str) { apply_value(style, name, value); } -fn apply_color_scheme( - style: &mut LayoutStyle, - value: &str, - inherited_scheme: bool, -) { +fn apply_color_scheme(style: &mut LayoutStyle, value: &str, inherited_scheme: bool) { let tokens: Vec = value .split_whitespace() .map(|token| token.to_ascii_lowercase()) .collect(); - if tokens.iter().any(|token| token == "inherit" || token == "unset") { + if tokens + .iter() + .any(|token| token == "inherit" || token == "unset") + { style.color_scheme_dark = inherited_scheme; return; } - if tokens.iter().any(|token| { - matches!(token.as_str(), "initial" | "revert" | "revert-layer") - }) { + if tokens + .iter() + .any(|token| matches!(token.as_str(), "initial" | "revert" | "revert-layer")) + { style.color_scheme_dark = false; return; } @@ -388,9 +420,7 @@ fn apply_color_scheme( // light therefore uses light; a dark-only list uses dark. `normal`, // initial/revert, and malformed values retain the default/inherited light // behavior used by this compact computed-style model. - if tokens.iter().any(|token| token == "light") - || tokens.iter().any(|token| token == "normal") - { + if tokens.iter().any(|token| token == "light") || tokens.iter().any(|token| token == "normal") { style.color_scheme_dark = false; } else if tokens.iter().any(|token| token == "dark") { style.color_scheme_dark = true; @@ -440,9 +470,7 @@ fn parse_container_type(value: &str) -> Option { "normal" => Some(crate::ContainerType::Normal), "inline-size" => Some(crate::ContainerType::InlineSize), "size" => Some(crate::ContainerType::Size), - "initial" | "unset" | "revert" | "revert-layer" => { - Some(crate::ContainerType::Normal) - } + "initial" | "unset" | "revert" | "revert-layer" => Some(crate::ContainerType::Normal), _ => None, } } @@ -474,8 +502,10 @@ fn parse_container_names(value: &str) -> Option> { fn parse_container_shorthand(value: &str) -> Option<(Vec, crate::ContainerType)> { let value = value.trim(); - if matches!(value.to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") - { + if matches!( + value.to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { return Some((Vec::new(), crate::ContainerType::Normal)); } let parts = split_top_level(value, '/'); @@ -628,8 +658,7 @@ fn parse_font_variation_number(parser: &mut cssparser::Parser<'_, '_>) -> Option .parse_nested_block(|input| unitless_math_tokens(input, 0)) .ok()?; let expression = parser.slice_from(start); - resolve_contextual_length(expression, 0.0, 0.0, 0.0, 0.0, 0.0) - .filter(|value| value.is_finite()) + resolve_contextual_length(expression, 0.0, 0.0, 0.0, 0.0, 0.0).filter(|value| value.is_finite()) } fn unitless_math_tokens<'i, 't>( @@ -640,9 +669,7 @@ fn unitless_math_tokens<'i, 't>( return Err(parser.new_custom_error(())); } while !parser.is_exhausted() { - let token = parser - .next_including_whitespace_and_comments()? - .clone(); + let token = parser.next_including_whitespace_and_comments()?.clone(); match token { cssparser::Token::Number { .. } | cssparser::Token::WhiteSpace(_) @@ -811,13 +838,17 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.container_type_inherit = false; } } - "width" => { + // The renderer currently supports the initial horizontal-tb writing + // mode. In that mode logical inline/block sizing is exactly the + // physical width/height pair, including declaration-order overrides + // between logical and physical longhands. + "width" | "inline-size" => { style.width = dimension_value(value); style.width_fit_content = value.trim().eq_ignore_ascii_case("fit-content"); style.size_expressions[0] = deferred_length_expression(value); style.width_set = true; } - "height" => { + "height" | "block-size" => { style.height = dimension_value(value); style.size_expressions[1] = deferred_length_expression(value); style.height_set = true; @@ -849,19 +880,19 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.box_sizing }; } - "min-width" => { + "min-width" | "min-inline-size" => { style.min_width = dimension_value(value); style.size_expressions[2] = deferred_length_expression(value); } - "min-height" => { + "min-height" | "min-block-size" => { style.min_height = dimension_value(value); style.size_expressions[3] = deferred_length_expression(value); } - "max-width" => { + "max-width" | "max-inline-size" => { style.max_width = dimension_value(value); style.size_expressions[4] = deferred_length_expression(value); } - "max-height" => { + "max-height" | "max-block-size" => { style.max_height = dimension_value(value); style.size_expressions[5] = deferred_length_expression(value); } @@ -876,10 +907,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "margin-bottom" => set_margin_side(style, 2, value), "margin-left" => set_margin_side(style, 3, value), // Logical margins (LTR: inline = left/right, block = top/bottom). - "margin-inline" => { let (s, e) = two(value); set_margin_side(style, 3, s); set_margin_side(style, 1, e); } + "margin-inline" => { + let (s, e) = two(value); + set_margin_side(style, 3, s); + set_margin_side(style, 1, e); + } "margin-inline-start" => set_margin_side(style, 3, value), "margin-inline-end" => set_margin_side(style, 1, value), - "margin-block" => { let (s, e) = two(value); set_margin_side(style, 0, s); set_margin_side(style, 2, e); } + "margin-block" => { + let (s, e) = two(value); + set_margin_side(style, 0, s); + set_margin_side(style, 2, e); + } "margin-block-start" => set_margin_side(style, 0, value), "margin-block-end" => set_margin_side(style, 2, value), "padding" => apply_padding_shorthand(style, value), @@ -887,10 +926,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "padding-right" => set_padding_side(style, 1, value), "padding-bottom" => set_padding_side(style, 2, value), "padding-left" => set_padding_side(style, 3, value), - "padding-inline" => { let (s, e) = two(value); set_padding_side(style, 3, s); set_padding_side(style, 1, e); } + "padding-inline" => { + let (s, e) = two(value); + set_padding_side(style, 3, s); + set_padding_side(style, 1, e); + } "padding-inline-start" => set_padding_side(style, 3, value), "padding-inline-end" => set_padding_side(style, 1, value), - "padding-block" => { let (s, e) = two(value); set_padding_side(style, 0, s); set_padding_side(style, 2, e); } + "padding-block" => { + let (s, e) = two(value); + set_padding_side(style, 0, s); + set_padding_side(style, 2, e); + } "padding-block-start" => set_padding_side(style, 0, value), "padding-block-end" => set_padding_side(style, 2, value), "border-radius" => apply_border_radius_shorthand(style, value), @@ -935,8 +982,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "outline-color" => set_outline_color(style, value), "outline-offset" => set_outline_offset(style, value), "background-color" => { - style.background_color = - parse_color_for_scheme(value, style.color_scheme_dark) + style.background_color = parse_color_for_scheme(value, style.color_scheme_dark) } "background" => { // A shorthand resets every omitted background longhand to its @@ -953,8 +999,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { && style.background_radial_gradient.is_none() && style.background_conic_gradient.is_none() { - style.background_color = - parse_color_for_scheme(value, style.color_scheme_dark); + style.background_color = parse_color_for_scheme(value, style.color_scheme_dark); } style.background_image = parse_url(value); style.background_size = None; @@ -993,9 +1038,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { // generated content is handled separately for pseudo-elements. "content" => style.content_image = parse_url(value), "mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value), - "mask-size" | "-webkit-mask-size" => { - style.mask_size = parse_background_size(value) - } + "mask-size" | "-webkit-mask-size" => style.mask_size = parse_background_size(value), "mask-repeat" | "-webkit-mask-repeat" => { style.mask_repeat = parse_image_repeat(value); } @@ -1113,39 +1156,39 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { if let Some(Some(value)) = self_alignment_value(value) { style.align_items = Some(value); } - }, + } "justify-items" => { if let Some(Some(value)) = self_alignment_value(value) { style.justify_items = Some(value); } - }, + } "place-items" => { if let Some((Some(align), Some(justify))) = self_alignment_pair(value) { style.align_items = Some(align); style.justify_items = Some(justify); } - }, + } "align-self" => { if let Some(value) = self_alignment_value(value) { style.align_self = value; } - }, + } "justify-self" => { if let Some(value) = self_alignment_value(value) { style.justify_self = value; } - }, + } "place-self" => { if let Some((align, justify)) = self_alignment_pair(value) { style.align_self = align; style.justify_self = justify; } - }, + } "align-content" => { if let Some(value) = content_alignment_value(value) { style.align_content = Some(value); } - }, + } "justify-content" => { let value = match value.trim().to_ascii_lowercase().as_str() { "left" => Some(taffy::JustifyContent::START), @@ -1155,7 +1198,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { if let Some(value) = value { style.justify_content = Some(value); } - }, + } "place-content" => { if let Some((align, justify)) = content_alignment_pair(value) { style.align_content = Some(align); @@ -1163,62 +1206,70 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "flex-direction" => match value { - "row" => style.flex_direction = Some(taffy::FlexDirection::Row), - "row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse), - "column" => style.flex_direction = Some(taffy::FlexDirection::Column), - "column-reverse" => style.flex_direction = Some(taffy::FlexDirection::ColumnReverse), - _ => {} + "row" => style.flex_direction = Some(taffy::FlexDirection::Row), + "row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse), + "column" => style.flex_direction = Some(taffy::FlexDirection::Column), + "column-reverse" => style.flex_direction = Some(taffy::FlexDirection::ColumnReverse), + _ => {} }, - "flex-wrap" => { - match value { - "wrap" => style.flex_wrap = Some(taffy::FlexWrap::Wrap), - "nowrap" => style.flex_wrap = Some(taffy::FlexWrap::NoWrap), - "wrap-reverse" => style.flex_wrap = Some(taffy::FlexWrap::WrapReverse), - _ => {} + "flex-wrap" => match value { + "wrap" => style.flex_wrap = Some(taffy::FlexWrap::Wrap), + "nowrap" => style.flex_wrap = Some(taffy::FlexWrap::NoWrap), + "wrap-reverse" => style.flex_wrap = Some(taffy::FlexWrap::WrapReverse), + _ => {} + }, + "flex-grow" => { + if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { + style.flex_grow = Some(v); } - }, - "flex-grow" => { if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { style.flex_grow = Some(v); } } - "flex-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { style.flex_shrink = Some(v); } } + } + "flex-shrink" => { + if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { + style.flex_shrink = Some(v); + } + } "order" => { if let Ok(order) = value.trim().parse::() { style.order = order; } } - "flex-basis" => { style.flex_basis = dimension_value(value.trim()); } + "flex-basis" => { + style.flex_basis = dimension_value(value.trim()); + } "flex" => parse_flex_shorthand(style, value), "position" => match value { - "absolute" => { - style.position = Some(taffy::Position::Absolute); - style.position_fixed = false; - style.position_sticky = false; - } - "fixed" => { - style.position = Some(taffy::Position::Absolute); - style.position_fixed = true; - style.position_sticky = false; - } - "relative" => { - style.position = Some(taffy::Position::Relative); - style.position_fixed = false; - style.position_sticky = false; - } - "sticky" => { - style.position = Some(taffy::Position::Relative); - style.position_fixed = false; - style.position_sticky = true; - } - "static" => { - style.position = None; - style.position_fixed = false; - style.position_sticky = false; - } - _ => {} + "absolute" => { + style.position = Some(taffy::Position::Absolute); + style.position_fixed = false; + style.position_sticky = false; + } + "fixed" => { + style.position = Some(taffy::Position::Absolute); + style.position_fixed = true; + style.position_sticky = false; + } + "relative" => { + style.position = Some(taffy::Position::Relative); + style.position_fixed = false; + style.position_sticky = false; + } + "sticky" => { + style.position = Some(taffy::Position::Relative); + style.position_fixed = false; + style.position_sticky = true; + } + "static" => { + style.position = None; + style.position_fixed = false; + style.position_sticky = false; + } + _ => {} }, "float" => match value { - "left" => style.float = Some(crate::Float::Left), - "right" => style.float = Some(crate::Float::Right), - "none" => style.float = None, - _ => {} + "left" => style.float = Some(crate::Float::Left), + "right" => style.float = Some(crate::Float::Right), + "none" => style.float = None, + _ => {} }, "counter-reset" => { if let Some(counters) = parse_counter_directives(value, 0) { @@ -1236,12 +1287,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "object-fit" => match value.trim().to_ascii_lowercase().as_str() { - "fill" => style.object_fit = crate::ObjectFit::Fill, - "contain" => style.object_fit = crate::ObjectFit::Contain, - "cover" => style.object_fit = crate::ObjectFit::Cover, - "scale-down" => style.object_fit = crate::ObjectFit::ScaleDown, - "none" => style.object_fit = crate::ObjectFit::None, - _ => {} + "fill" => style.object_fit = crate::ObjectFit::Fill, + "contain" => style.object_fit = crate::ObjectFit::Contain, + "cover" => style.object_fit = crate::ObjectFit::Cover, + "scale-down" => style.object_fit = crate::ObjectFit::ScaleDown, + "none" => style.object_fit = crate::ObjectFit::None, + _ => {} }, "top" => set_inset_side(style, 0, value), "right" => set_inset_side(style, 1, value), @@ -1317,31 +1368,44 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "opacity" => style.opacity = value.trim().parse::().ok(), "animation" => apply_animation_shorthand(style, value), "animation-name" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); style.animation_name = if first.is_empty() || matches!( first.to_ascii_lowercase().as_str(), "none" | "initial" | "inherit" | "unset" | "revert" | "revert-layer" - ) - { + ) { None } else { Some(first.to_string()) }; } "animation-duration" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" ) { style.animation_timing.duration_ms = 0.0; - } else if let Some(milliseconds) = parse_animation_time_ms(first).filter(|time| *time >= 0.0) { + } else if let Some(milliseconds) = + parse_animation_time_ms(first).filter(|time| *time >= 0.0) + { style.animation_timing.duration_ms = milliseconds; } } "animation-delay" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" @@ -1352,7 +1416,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "animation-fill-mode" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" @@ -1363,7 +1431,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "animation-iteration-count" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" @@ -1374,7 +1446,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "animation-direction" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" @@ -1385,7 +1461,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "animation-play-state" => { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if matches!( first.to_ascii_lowercase().as_str(), "initial" | "inherit" | "unset" | "revert" | "revert-layer" @@ -1419,7 +1499,8 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { }; } "list-style-type" => { - style.list_style = Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc)); + style.list_style = + Some(list_style_keyword(value.trim()).unwrap_or(crate::ListStyle::Disc)); } "list-style" => { // Shorthand: type | position | image in any order. We only track @@ -1444,7 +1525,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } else if let Some(pct) = v.strip_suffix('%') { pct.trim().parse::().ok().map(|number| { crate::LineHeight::Relative(crate::Dimension::Percent(number / 100.0)) - }) + }) } else if v.ends_with("px") || v.ends_with("pt") { px_value(v).map(crate::LineHeight::Px) } else if ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"] @@ -1459,9 +1540,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "white-space" => { style.white_space = match value.trim().to_ascii_lowercase().as_str() { - "normal" | "initial" | "revert" | "revert-layer" => { - Some(crate::WhiteSpace::Normal) - } + "normal" | "initial" | "revert" | "revert-layer" => Some(crate::WhiteSpace::Normal), "nowrap" => Some(crate::WhiteSpace::NoWrap), "pre" => Some(crate::WhiteSpace::Pre), "pre-wrap" => Some(crate::WhiteSpace::PreWrap), @@ -1489,7 +1568,9 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "-webkit-box-orient" => { style.webkit_box_orient_vertical = match value.trim().to_ascii_lowercase().as_str() { "vertical" | "block-axis" => true, - "horizontal" | "inline-axis" | "initial" | "unset" | "revert" | "revert-layer" => false, + "horizontal" | "inline-axis" | "initial" | "unset" | "revert" | "revert-layer" => { + false + } _ => style.webkit_box_orient_vertical, }; } @@ -1528,9 +1609,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "text-wrap-style" => { style.text_wrap_style = match value.trim().to_ascii_lowercase().as_str() { - "auto" | "initial" | "revert" | "revert-layer" => { - Some(crate::TextWrapStyle::Auto) - } + "auto" | "initial" | "revert" | "revert-layer" => Some(crate::TextWrapStyle::Auto), "balance" => Some(crate::TextWrapStyle::Balance), // `text-wrap-style` inherits, so unset behaves as inherit. "inherit" | "unset" => None, @@ -1552,7 +1631,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "text-decoration" | "text-decoration-line" => { // Shorthand can carry color/style/thickness; we only model the // underline line (the dominant case, and the UA default for links). - let toks: Vec = value.split_whitespace().map(|t| t.to_ascii_lowercase()).collect(); + let toks: Vec = value + .split_whitespace() + .map(|t| t.to_ascii_lowercase()) + .collect(); let underline = toks.iter().any(|t| t == "underline"); let none = toks.iter().any(|t| t == "none"); style.underline = Some(underline && !none); @@ -1580,8 +1662,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { .find_map(parse_column_count); } "break-inside" => { - style.break_inside_avoid = - matches!(value.trim().to_ascii_lowercase().as_str(), "avoid" | "avoid-column"); + style.break_inside_avoid = matches!( + value.trim().to_ascii_lowercase().as_str(), + "avoid" | "avoid-column" + ); } "-webkit-column-break-inside" => { style.break_inside_avoid = value.trim().eq_ignore_ascii_case("avoid"); @@ -1631,11 +1715,9 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "translate" => parse_individual_translate(style, value), "rotate" => parse_individual_rotate(style, value), "scale" => parse_individual_scale(style, value), - "filter" => set_containing_block_trigger( - style, - crate::CB_TRIGGER_FILTER, - non_none_value(value), - ), + "filter" => { + set_containing_block_trigger(style, crate::CB_TRIGGER_FILTER, non_none_value(value)) + } "backdrop-filter" | "-webkit-backdrop-filter" => set_containing_block_trigger( style, crate::CB_TRIGGER_BACKDROP_FILTER, @@ -1647,16 +1729,21 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { non_none_value(value), ), "contain" => { - let establishes = value - .split_whitespace() - .any(|v| matches!(v.to_ascii_lowercase().as_str(), "layout" | "paint" | "strict" | "content")); + let establishes = value.split_whitespace().any(|v| { + matches!( + v.to_ascii_lowercase().as_str(), + "layout" | "paint" | "strict" | "content" + ) + }); set_containing_block_trigger(style, crate::CB_TRIGGER_CONTAIN, establishes); } "will-change" => { - let establishes = value - .split([',', ' ']) - .map(str::trim) - .any(|v| matches!(v.to_ascii_lowercase().as_str(), "transform" | "filter" | "backdrop-filter" | "perspective" | "contain")); + let establishes = value.split([',', ' ']).map(str::trim).any(|v| { + matches!( + v.to_ascii_lowercase().as_str(), + "transform" | "filter" | "backdrop-filter" | "perspective" | "contain" + ) + }); set_containing_block_trigger(style, crate::CB_TRIGGER_WILL_CHANGE, establishes); } "content-visibility" => set_containing_block_trigger( @@ -1665,11 +1752,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { value.trim().eq_ignore_ascii_case("auto"), ), "box-shadow" | "-webkit-box-shadow" => { - style.box_shadow = parse_box_shadow( - value, - style.color, - style.color_scheme_dark, - ); + style.box_shadow = parse_box_shadow(value, style.color, style.color_scheme_dark); } _ => {} } @@ -1680,7 +1763,9 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { /// provenance. Calling this after every accepted declaration makes the result /// independent of whether display, orientation, or clamp appeared first. pub(crate) fn normalize_webkit_line_clamp_display(style: &mut LayoutStyle) { - let Some(inline) = style.webkit_box_display else { return }; + let Some(inline) = style.webkit_box_display else { + return; + }; if style.webkit_box_orient_vertical && style.webkit_line_clamp.is_some() { style.display = crate::Display::Block; style.is_inline_block = inline; @@ -1744,11 +1829,17 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { name.as_str(), "display" | "width" + | "inline-size" | "height" + | "block-size" | "min-width" + | "min-inline-size" | "min-height" + | "min-block-size" | "max-width" + | "max-inline-size" | "max-height" + | "max-block-size" | "box-sizing" | "container" | "container-type" @@ -1985,22 +2076,15 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { "container-type" => parse_container_type(value).is_some(), "container-name" => parse_container_names(value).is_some(), "container" => parse_container_shorthand(value).is_some(), - "font-optical-sizing" => matches!( - value.to_ascii_lowercase().as_str(), - "auto" | "none" - ), + "font-optical-sizing" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "none"), "font-variation-settings" => { - value.eq_ignore_ascii_case("normal") - || parse_font_variation_settings(value).is_some() + value.eq_ignore_ascii_case("normal") || parse_font_variation_settings(value).is_some() } "white-space" => matches!( value.to_ascii_lowercase().as_str(), "normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces" ), - "text-overflow" => matches!( - value.to_ascii_lowercase().as_str(), - "clip" | "ellipsis" - ), + "text-overflow" => matches!(value.to_ascii_lowercase().as_str(), "clip" | "ellipsis"), "-webkit-line-clamp" => webkit_line_clamp_value(value).is_some(), "-webkit-box-orient" => matches!( value.to_ascii_lowercase().as_str(), @@ -2019,9 +2103,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { "auto" | "wrap" | "balance" | "wrap balance" | "balance wrap" ), "text-wrap-style" => matches!(value.to_ascii_lowercase().as_str(), "auto" | "balance"), - "text-indent" => { - parse_text_indent(value).is_some() - } + "text-indent" => parse_text_indent(value).is_some(), "animation-duration" => parse_animation_time_ms(value).is_some_and(|time| time >= 0.0), "animation-delay" => parse_animation_time_ms(value).is_some(), "animation-iteration-count" => parse_animation_iteration_count(value).is_some(), @@ -2067,13 +2149,11 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { .collect::>(); !parts.is_empty() && parts.len() <= 4 - && parts.iter().all(|part| { - !part.is_empty() && parse_grid_line_kind(part).is_some() - }) - } - "grid-auto-columns" | "grid-auto-rows" => { - parse_grid_auto_track_list(value).is_some() + && parts + .iter() + .all(|part| !part.is_empty() && parse_grid_line_kind(part).is_some()) } + "grid-auto-columns" | "grid-auto-rows" => parse_grid_auto_track_list(value).is_some(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left" => { parse_border_shorthand(value, false).is_some() } @@ -2082,25 +2162,37 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { .iter() .map(|token| border_width(token)) .collect::>>(); - values.and_then(|values| crate::border::expand_sides(&values)).is_some() + values + .and_then(|values| crate::border::expand_sides(&values)) + .is_some() } - "border-top-width" | "border-right-width" | "border-bottom-width" | "border-left-width" + "border-top-width" + | "border-right-width" + | "border-bottom-width" + | "border-left-width" | "outline-width" => border_width(value).is_some(), "border-style" => { let values = split_ws_paren(value) .iter() .map(|token| border_style(token)) .collect::>>(); - values.and_then(|values| crate::border::expand_sides(&values)).is_some() + values + .and_then(|values| crate::border::expand_sides(&values)) + .is_some() } "border-top-style" | "border-right-style" | "border-bottom-style" | "border-left-style" => { border_style(value).is_some() } "border-color" => parse_border_colors(value, false).is_some(), - "border-top-color" | "border-right-color" | "border-bottom-color" | "border-left-color" + "border-top-color" + | "border-right-color" + | "border-bottom-color" + | "border-left-color" | "outline-color" => border_color(value, false).is_some(), "border-radius" => parsed_border_radii(value).is_some(), - "border-top-left-radius" | "border-top-right-radius" | "border-bottom-right-radius" + "border-top-left-radius" + | "border-top-right-radius" + | "border-bottom-right-radius" | "border-bottom-left-radius" => { let tokens = split_ws_paren(value); matches!(tokens.as_slice(), [one] if radius_value(one).is_some()) @@ -2109,9 +2201,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { "outline" => parse_outline_shorthand(value, false).is_some(), "outline-style" => outline_style(value).is_some(), "outline-offset" => strict_border_length(value).is_some(), - "color" | "-webkit-text-fill-color" | "background-color" => { - parse_color(value).is_some() - } + "color" | "-webkit-text-fill-color" | "background-color" => parse_color(value).is_some(), "color-scheme" => { let tokens: Vec<&str> = value.split_whitespace().collect(); !tokens.is_empty() @@ -2188,7 +2278,9 @@ fn supports_content_value(value: &str) -> bool { let mut found = false; while !rest.is_empty() { rest = rest.trim_start(); - let Some(first) = rest.chars().next() else { break }; + let Some(first) = rest.chars().next() else { + break; + }; if matches!(first, '\'' | '"') { let mut escaped = false; let mut end = None; @@ -2209,7 +2301,9 @@ fn supports_content_value(value: &str) -> bool { } let name_end = rest .char_indices() - .find_map(|(index, ch)| (!ch.is_alphanumeric() && !matches!(ch, '-' | '_' | '\\')).then_some(index)) + .find_map(|(index, ch)| { + (!ch.is_alphanumeric() && !matches!(ch, '-' | '_' | '\\')).then_some(index) + }) .unwrap_or(rest.len()); let name = rest[..name_end].to_ascii_lowercase(); let tail = rest[name_end..].trim_start(); @@ -2315,9 +2409,9 @@ fn supports_content_function(name: &str, arguments: &str) -> bool { let ident = |value: &str| { let value = value.trim(); !value.is_empty() - && value - .chars() - .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_' | '\\')) + && value.chars().all(|character| { + character.is_alphanumeric() || matches!(character, '-' | '_' | '\\') + }) }; let counter_style = |value: &str| { matches!( @@ -2333,23 +2427,23 @@ fn supports_content_function(name: &str, arguments: &str) -> bool { ) }; match name { - "attr" => arguments.len() == 1 - && arguments[0] - .split_whitespace() - .next() - .is_some_and(ident), - "counter" => (1..=2).contains(&arguments.len()) - && ident(arguments[0]) - && arguments.get(1).is_none_or(|value| counter_style(value)), - "counters" => (2..=3).contains(&arguments.len()) - && ident(arguments[0]) - && { - let separator = arguments[1].trim(); - separator.len() >= 2 - && matches!(separator.as_bytes()[0], b'\'' | b'"') - && separator.as_bytes().last() == separator.as_bytes().first() - } - && arguments.get(2).is_none_or(|value| counter_style(value)), + "attr" => arguments.len() == 1 && arguments[0].split_whitespace().next().is_some_and(ident), + "counter" => { + (1..=2).contains(&arguments.len()) + && ident(arguments[0]) + && arguments.get(1).is_none_or(|value| counter_style(value)) + } + "counters" => { + (2..=3).contains(&arguments.len()) + && ident(arguments[0]) + && { + let separator = arguments[1].trim(); + separator.len() >= 2 + && matches!(separator.as_bytes()[0], b'\'' | b'"') + && separator.as_bytes().last() == separator.as_bytes().first() + } + && arguments.get(2).is_none_or(|value| counter_style(value)) + } _ => false, } } @@ -2373,20 +2467,32 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { }; let dimensions = |input: &str, auto: bool, max: usize| { let tokens = split_ws_paren(input); - !tokens.is_empty() && tokens.len() <= max && tokens.iter().all(|token| dimension(token, auto)) + !tokens.is_empty() + && tokens.len() <= max + && tokens.iter().all(|token| dimension(token, auto)) }; match name { - "width" => lower == "fit-content" || dimension(value, true), - "height" | "min-width" | "min-height" | "max-width" | "max-height" | "flex-basis" => { - dimension(value, true) - } + "width" | "inline-size" => lower == "fit-content" || dimension(value, true), + "height" | "block-size" | "min-width" | "min-inline-size" | "min-height" + | "min-block-size" | "max-width" | "max-inline-size" | "max-height" | "max-block-size" + | "flex-basis" => dimension(value, true), "margin" | "margin-inline" | "margin-block" => dimensions(value, true, 4), - "margin-top" | "margin-right" | "margin-bottom" | "margin-left" - | "margin-inline-start" | "margin-inline-end" | "margin-block-start" + "margin-top" + | "margin-right" + | "margin-bottom" + | "margin-left" + | "margin-inline-start" + | "margin-inline-end" + | "margin-block-start" | "margin-block-end" => dimension(value, true), "padding" | "padding-inline" | "padding-block" | "inset" => dimensions(value, false, 4), - "padding-top" | "padding-right" | "padding-bottom" | "padding-left" - | "padding-inline-start" | "padding-inline-end" | "padding-block-start" + "padding-top" + | "padding-right" + | "padding-bottom" + | "padding-left" + | "padding-inline-start" + | "padding-inline-end" + | "padding-block-start" | "padding-block-end" => dimension(value, false), "top" | "right" | "bottom" | "left" | "inset-inline" | "inset-block" => { dimensions(value, true, 2) @@ -2400,49 +2506,87 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { } "place-items" | "place-self" => self_alignment_pair(value).is_some(), "align-content" => content_alignment_value(value).is_some(), - "justify-content" => matches!(lower.as_str(), "left" | "right") || content_alignment_value(value).is_some(), + "justify-content" => { + matches!(lower.as_str(), "left" | "right") || content_alignment_value(value).is_some() + } "place-content" => content_alignment_pair(value).is_some(), - "flex-direction" => matches!(lower.as_str(), "row" | "row-reverse" | "column" | "column-reverse"), + "flex-direction" => matches!( + lower.as_str(), + "row" | "row-reverse" | "column" | "column-reverse" + ), "flex-wrap" => matches!(lower.as_str(), "nowrap" | "wrap" | "wrap-reverse"), - "flex-grow" | "flex-shrink" => finite_number(value) && value.trim().parse::().is_ok_and(|number| number >= 0.0), + "flex-grow" | "flex-shrink" => { + finite_number(value) + && value + .trim() + .parse::() + .is_ok_and(|number| number >= 0.0) + } "flex" => supports_flex_value(value), "order" => value.trim().parse::().is_ok(), "opacity" => finite_number(value), "z-index" => lower == "auto" || value.trim().parse::().is_ok(), - "clear" => matches!(lower.as_str(), "none" | "left" | "right" | "both" | "inline-start" | "inline-end"), - "vertical-align" => matches!(lower.as_str(), "top" | "baseline" | "text-top" | "middle" | "bottom" | "text-bottom"), + "clear" => matches!( + lower.as_str(), + "none" | "left" | "right" | "both" | "inline-start" | "inline-end" + ), + "vertical-align" => matches!( + lower.as_str(), + "top" | "baseline" | "text-top" | "middle" | "bottom" | "text-bottom" + ), "border-collapse" => matches!(lower.as_str(), "collapse" | "separate"), "border-spacing" => dimensions(value, false, 2), - "column-count" | "-webkit-column-count" => lower == "auto" || parse_column_count(value).is_some(), - "columns" | "-webkit-columns" => split_ws_paren(value).into_iter().any(|token| parse_column_count(token).is_some()), + "column-count" | "-webkit-column-count" => { + lower == "auto" || parse_column_count(value).is_some() + } + "columns" | "-webkit-columns" => split_ws_paren(value) + .into_iter() + .any(|token| parse_column_count(token).is_some()), "break-inside" => matches!(lower.as_str(), "auto" | "avoid" | "avoid-column"), "-webkit-column-break-inside" => matches!(lower.as_str(), "auto" | "avoid"), "grid-auto-flow" => { let tokens = lower.split_whitespace().collect::>(); - !tokens.is_empty() && tokens.len() <= 2 && tokens.iter().all(|token| matches!(*token, "row" | "column" | "dense")) + !tokens.is_empty() + && tokens.len() <= 2 + && tokens + .iter() + .all(|token| matches!(*token, "row" | "column" | "dense")) } "grid-template-columns" | "grid-template-rows" => { let (tracks, _) = parse_track_list_named(value); !tracks.is_empty() || lower.starts_with("subgrid") } "grid-template-areas" => !parse_grid_areas(value).is_empty() || lower == "none", - "grid-column" | "grid-row" => split_top_level(value, '/').into_iter().all(|part| parse_grid_line_kind(part.trim()).is_some()), - "grid-column-start" | "grid-column-end" | "grid-row-start" | "grid-row-end" => parse_grid_line_kind(value).is_some(), + "grid-column" | "grid-row" => split_top_level(value, '/') + .into_iter() + .all(|part| parse_grid_line_kind(part.trim()).is_some()), + "grid-column-start" | "grid-column-end" | "grid-row-start" | "grid-row-end" => { + parse_grid_line_kind(value).is_some() + } "transform-origin" => parse_transform_origin(value).is_some(), "translate" => lower == "none" || dimensions(value, false, 3), "rotate" => lower == "none" || angle_degrees(value).is_some(), - "scale" => lower == "none" || { - let values = value.split_whitespace().collect::>(); - !values.is_empty() && values.len() <= 2 && values.iter().all(|value| scale_number(value).is_some()) - }, + "scale" => { + lower == "none" || { + let values = value.split_whitespace().collect::>(); + !values.is_empty() + && values.len() <= 2 + && values.iter().all(|value| scale_number(value).is_some()) + } + } "transform" => supports_transform_value(value), "background-color" | "color" | "-webkit-text-fill-color" => parse_color(value).is_some(), "background-image" | "mask-image" | "-webkit-mask-image" => { - lower == "none" || parse_url(value).is_some() || !parse_background_gradient_layers(value, false).is_empty() + lower == "none" + || parse_url(value).is_some() + || !parse_background_gradient_layers(value, false).is_empty() + } + "background-repeat" | "mask-repeat" | "-webkit-mask-repeat" => { + parse_image_repeat(value).is_some() } - "background-repeat" | "mask-repeat" | "-webkit-mask-repeat" => parse_image_repeat(value).is_some(), "background-size" | "mask-size" | "-webkit-mask-size" => { - matches!(lower.as_str(), "auto" | "cover" | "contain") || parse_background_size(value).is_some() + matches!(lower.as_str(), "auto" | "cover" | "contain") + || parse_background_size(value).is_some() } "background-origin" => parse_background_origin(value).is_some(), "background-clip" | "-webkit-background-clip" => parse_background_clip(value).is_some(), @@ -2450,17 +2594,32 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { "font-weight" => specified_font_weight(value).is_some(), "font-family" => !value.trim().is_empty(), "font-style" => lower == "normal" || lower == "italic" || lower.starts_with("oblique"), - "text-align" => matches!(lower.as_str(), "left" | "right" | "start" | "end" | "center" | "justify"), - "text-transform" => matches!(lower.as_str(), "none" | "uppercase" | "lowercase" | "capitalize"), - "text-decoration" | "text-decoration-line" => lower.split_whitespace().all(|token| matches!(token, "none" | "underline")), + "text-align" => matches!( + lower.as_str(), + "left" | "right" | "start" | "end" | "center" | "justify" + ), + "text-transform" => matches!( + lower.as_str(), + "none" | "uppercase" | "lowercase" | "capitalize" + ), + "text-decoration" | "text-decoration-line" => lower + .split_whitespace() + .all(|token| matches!(token, "none" | "underline")), "line-height" => lower == "normal" || finite_number(value) || dimension(value, false), "gap" | "grid-gap" => dimensions(value, false, 2), - "row-gap" | "grid-row-gap" | "column-gap" | "grid-column-gap" | "-webkit-column-gap" => lower == "normal" || dimension(value, false), - "counter-reset" | "counter-increment" | "counter-set" => parse_counter_directives(value, 0).is_some(), + "row-gap" | "grid-row-gap" | "column-gap" | "grid-column-gap" | "-webkit-column-gap" => { + lower == "normal" || dimension(value, false) + } + "counter-reset" | "counter-increment" | "counter-set" => { + parse_counter_directives(value, 0).is_some() + } "list-style-type" => list_style_keyword(value.trim()).is_some(), - "list-style" => value.split_whitespace().any(|token| list_style_keyword(token).is_some()), + "list-style" => value + .split_whitespace() + .any(|token| list_style_keyword(token).is_some()), "animation" | "animation-name" => !value.trim().is_empty(), - "background" | "font" | "grid-template" | "grid" | "box-shadow" | "-webkit-box-shadow" | "fill" | "stroke" | "stroke-width" | "letter-spacing" | "will-change" => false, + "background" | "font" | "grid-template" | "grid" | "box-shadow" | "-webkit-box-shadow" + | "fill" | "stroke" | "stroke-width" | "letter-spacing" | "will-change" => false, _ => false, } } @@ -2476,9 +2635,15 @@ fn supports_flex_value(value: &str) -> bool { } let mut numbers = 0usize; for token in tokens { - if token.parse::().ok().is_some_and(|number| number.is_finite() && number >= 0.0) { + if token + .parse::() + .ok() + .is_some_and(|number| number.is_finite() && number >= 0.0) + { numbers += 1; - } else if matches!(dimension_value(token), crate::Dimension::Auto) && !token.eq_ignore_ascii_case("auto") { + } else if matches!(dimension_value(token), crate::Dimension::Auto) + && !token.eq_ignore_ascii_case("auto") + { return false; } } @@ -2493,7 +2658,11 @@ fn supports_transform_value(value: &str) -> bool { } fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) { - let first = split_top_level(value, ',').into_iter().next().unwrap_or("").trim(); + let first = split_top_level(value, ',') + .into_iter() + .next() + .unwrap_or("") + .trim(); if first.is_empty() { return; } @@ -2586,7 +2755,10 @@ fn parse_animation_iteration_count(value: &str) -> Option { if value.eq_ignore_ascii_case("infinite") { return Some(f32::INFINITY); } - value.parse::().ok().filter(|count| count.is_finite() && *count >= 0.0) + value + .parse::() + .ok() + .filter(|count| count.is_finite() && *count >= 0.0) } #[derive(Clone, Copy)] @@ -2647,7 +2819,11 @@ struct AnimationCalcParser<'a> { impl AnimationCalcParser<'_> { fn skip_whitespace(&mut self) { - while self.input.get(self.position).is_some_and(u8::is_ascii_whitespace) { + while self + .input + .get(self.position) + .is_some_and(u8::is_ascii_whitespace) + { self.position += 1; } } @@ -2813,10 +2989,9 @@ fn self_alignment_pair( for split in 1..tokens.len() { let align = tokens[..split].join(" "); let justify = tokens[split..].join(" "); - if let (Some(align), Some(justify)) = ( - self_alignment_value(&align), - self_alignment_value(&justify), - ) { + if let (Some(align), Some(justify)) = + (self_alignment_value(&align), self_alignment_value(&justify)) + { return Some((align, justify)); } } @@ -2849,9 +3024,7 @@ fn content_alignment_value(value: &str) -> Option { }) } -fn content_alignment_pair( - value: &str, -) -> Option<(taffy::AlignContent, taffy::JustifyContent)> { +fn content_alignment_pair(value: &str) -> Option<(taffy::AlignContent, taffy::JustifyContent)> { if let Some(alignment) = content_alignment_value(value) { return Some((alignment, alignment)); } @@ -2898,18 +3071,17 @@ fn parse_transform_length(value: &str) -> Option { } let (name, arguments) = &functions[0]; let valid = match name.to_ascii_lowercase().as_str() { - "calc" | "min" | "max" | "clamp" => resolve_contextual_length( - value, 16.0, 16.0, 10.0, 10.0, 100.0, - ) - .is_some_and(f32::is_finite), + "calc" | "min" | "max" | "clamp" => { + resolve_contextual_length(value, 16.0, 16.0, 10.0, 10.0, 100.0) + .is_some_and(f32::is_finite) + } "var" => { let parts = split_top_level(arguments, ','); (1..=2).contains(&parts.len()) && parts[0].trim().starts_with("--") && parts[0].trim().len() > 2 && parts.get(1).is_none_or(|fallback| { - !fallback.trim().is_empty() - && parse_transform_length(fallback).is_some() + !fallback.trim().is_empty() && parse_transform_length(fallback).is_some() }) } _ => false, @@ -2956,10 +3128,7 @@ fn parse_transform_ops(value: &str) -> Option> { } else { crate::TransformLength::px(0.0) }; - crate::TransformOp::Translate( - parse_transform_length(values[0])?, - y, - ) + crate::TransformOp::Translate(parse_transform_length(values[0])?, y) } "translatex" if values.len() == 1 => crate::TransformOp::Translate( parse_transform_length(values[0])?, @@ -2985,10 +3154,20 @@ fn parse_transform_ops(value: &str) -> Option> { } "scale" if (1..=2).contains(&values.len()) => { let x = scale_number(values[0])?; - crate::TransformOp::Scale(x, values.get(1).and_then(|value| scale_number(value)).unwrap_or(x)) + crate::TransformOp::Scale( + x, + values + .get(1) + .and_then(|value| scale_number(value)) + .unwrap_or(x), + ) + } + "scalex" if values.len() == 1 => { + crate::TransformOp::Scale(scale_number(values[0])?, 1.0) + } + "scaley" if values.len() == 1 => { + crate::TransformOp::Scale(1.0, scale_number(values[0])?) } - "scalex" if values.len() == 1 => crate::TransformOp::Scale(scale_number(values[0])?, 1.0), - "scaley" if values.len() == 1 => crate::TransformOp::Scale(1.0, scale_number(values[0])?), "scale3d" if values.len() == 3 => { let x = scale_number(values[0])?; let y = scale_number(values[1])?; @@ -3031,34 +3210,60 @@ fn parse_transform_ops(value: &str) -> Option> { } "skew" if (1..=2).contains(&values.len()) => crate::TransformOp::Skew( angle_degrees(values[0])?, - values.get(1).and_then(|value| angle_degrees(value)).unwrap_or(0.0), + values + .get(1) + .and_then(|value| angle_degrees(value)) + .unwrap_or(0.0), ), - "skewx" if values.len() == 1 => crate::TransformOp::Skew(angle_degrees(values[0])?, 0.0), - "skewy" if values.len() == 1 => crate::TransformOp::Skew(0.0, angle_degrees(values[0])?), + "skewx" if values.len() == 1 => { + crate::TransformOp::Skew(angle_degrees(values[0])?, 0.0) + } + "skewy" if values.len() == 1 => { + crate::TransformOp::Skew(0.0, angle_degrees(values[0])?) + } "matrix" if values.len() == 6 => { - let numbers = values.iter().map(|value| value.parse::().ok()).collect::>>()?; + let numbers = values + .iter() + .map(|value| value.parse::().ok()) + .collect::>>()?; if numbers.iter().any(|value| !value.is_finite()) { return None; } crate::TransformOp::Matrix(crate::Affine2 { - a: numbers[0], b: numbers[1], c: numbers[2], - d: numbers[3], e: numbers[4], f: numbers[5], + a: numbers[0], + b: numbers[1], + c: numbers[2], + d: numbers[3], + e: numbers[4], + f: numbers[5], }) } "matrix3d" if values.len() == 16 => { - let numbers = values.iter().map(|value| value.parse::().ok()).collect::>>()?; + let numbers = values + .iter() + .map(|value| value.parse::().ok()) + .collect::>>()?; if numbers.iter().any(|value| !value.is_finite()) - || numbers[2] != 0.0 || numbers[3] != 0.0 - || numbers[6] != 0.0 || numbers[7] != 0.0 - || numbers[8] != 0.0 || numbers[9] != 0.0 - || numbers[10] != 1.0 || numbers[11] != 0.0 - || numbers[14] != 0.0 || numbers[15] != 1.0 + || numbers[2] != 0.0 + || numbers[3] != 0.0 + || numbers[6] != 0.0 + || numbers[7] != 0.0 + || numbers[8] != 0.0 + || numbers[9] != 0.0 + || numbers[10] != 1.0 + || numbers[11] != 0.0 + || numbers[14] != 0.0 + || numbers[15] != 1.0 { return None; } crate::TransformOp::Matrix(crate::Affine2 { - a: numbers[0], b: numbers[1], c: numbers[4], - d: numbers[5], e: numbers[12], f: numbers[13], + a: numbers[0], + b: numbers[1], + c: numbers[4], + d: numbers[5], + e: numbers[12], + f: numbers[13], }) } _ => return None, @@ -3076,11 +3281,7 @@ fn angle_degrees(value: &str) -> Option { } else if let Some(number) = value.strip_suffix("grad") { number.trim().parse::().ok().map(|value| value * 0.9) } else if let Some(number) = value.strip_suffix("rad") { - number - .trim() - .parse::() - .ok() - .map(f32::to_degrees) + number.trim().parse::().ok().map(f32::to_degrees) } else if let Some(number) = value.strip_suffix("turn") { number.trim().parse::().ok().map(|value| value * 360.0) } else if value == "0" { @@ -3164,7 +3365,9 @@ fn parse_individual_rotate(style: &mut LayoutStyle, value: &str) { set_containing_block_trigger(style, crate::CB_TRIGGER_ROTATE, false); return; } - let Some(angle) = angle_degrees(value) else { return }; + let Some(angle) = angle_degrees(value) else { + return; + }; style.individual_rotate = Some(angle); set_containing_block_trigger(style, crate::CB_TRIGGER_ROTATE, true); } @@ -3393,17 +3596,33 @@ fn tokenize_tracks(value: &str) -> Vec { let mut in_bracket = false; for c in value.chars() { match c { - '[' => { in_bracket = true; cur.push(c); } - ']' => { in_bracket = false; cur.push(c); } - '(' => { depth += 1; cur.push(c); } - ')' => { depth -= 1; cur.push(c); } + '[' => { + in_bracket = true; + cur.push(c); + } + ']' => { + in_bracket = false; + cur.push(c); + } + '(' => { + depth += 1; + cur.push(c); + } + ')' => { + depth -= 1; + cur.push(c); + } c if c.is_whitespace() && depth == 0 && !in_bracket => { - if !cur.is_empty() { out.push(std::mem::take(&mut cur)); } + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } } c => cur.push(c), } } - if !cur.is_empty() { out.push(cur); } + if !cur.is_empty() { + out.push(cur); + } out } @@ -3411,19 +3630,31 @@ fn track(tok: &str) -> taffy::TrackSizingFunction { use taffy::MinMax; let t = tok.trim(); let lower = t.to_ascii_lowercase(); - if let Some(inner) = lower.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) { + if let Some(inner) = lower + .strip_prefix("minmax(") + .and_then(|s| s.strip_suffix(')')) + { if let Some((a, b)) = inner.split_once(',') { - return MinMax { min: min_track(a.trim()), max: max_track(b.trim()) }; + return MinMax { + min: min_track(a.trim()), + max: max_track(b.trim()), + }; } } - if let Some(inner) = lower.strip_prefix("fit-content(").and_then(|s| s.strip_suffix(')')) { + if let Some(inner) = lower + .strip_prefix("fit-content(") + .and_then(|s| s.strip_suffix(')')) + { let px = px_value(inner.trim()).unwrap_or(0.0); return MinMax { min: taffy::MinTrackSizingFunction::auto(), max: taffy::MaxTrackSizingFunction::fit_content_px(px), }; } - MinMax { min: min_track(t), max: max_track(t) } + MinMax { + min: min_track(t), + max: max_track(t), + } } /// Parse the `+` grammar shared by `grid-auto-columns` and @@ -3488,7 +3719,9 @@ fn valid_grid_track_length_percentage(value: &str) -> bool { if lower == "0" { return true; } - for suffix in ["rem", "vmin", "vmax", "px", "pt", "em", "ex", "vw", "vh", "%"] { + for suffix in [ + "rem", "vmin", "vmax", "px", "pt", "em", "ex", "vw", "vh", "%", + ] { if let Some(number) = lower.strip_suffix(suffix) { return number .trim() @@ -3536,7 +3769,10 @@ fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { if other.ends_with("fr") { // Flexible tracks have an automatic minimum. M::auto() - } else if let Some(p) = other.strip_suffix('%').and_then(|n| n.trim().parse::().ok()) { + } else if let Some(p) = other + .strip_suffix('%') + .and_then(|n| n.trim().parse::().ok()) + { M::percent(p / 100.0) } else if let Some(px) = px_value(other) { M::length(px) @@ -3555,9 +3791,15 @@ fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction { "max-content" => M::max_content(), "auto" => M::auto(), other => { - if let Some(fr) = other.strip_suffix("fr").and_then(|n| n.trim().parse::().ok()) { + if let Some(fr) = other + .strip_suffix("fr") + .and_then(|n| n.trim().parse::().ok()) + { M::fr(fr) - } else if let Some(p) = other.strip_suffix('%').and_then(|n| n.trim().parse::().ok()) { + } else if let Some(p) = other + .strip_suffix('%') + .and_then(|n| n.trim().parse::().ok()) + { M::percent(p / 100.0) } else if let Some(px) = px_value(other) { M::length(px) @@ -3577,7 +3819,11 @@ fn parse_grid_areas(value: &str) -> Vec> { match c { '\'' | '"' => { if in_str { - rows.push(cur.split_whitespace().map(|s| s.to_string()).collect::>()); + rows.push( + cur.split_whitespace() + .map(|s| s.to_string()) + .collect::>(), + ); cur.clear(); in_str = false; } else { @@ -3727,11 +3973,7 @@ fn set_grid_area(style: &mut LayoutStyle, value: &str) { style.grid_area_name = None; } - set_grid_placement( - style, - &format!("{column_start} / {column_end}"), - true, - ); + set_grid_placement(style, &format!("{column_start} / {column_end}"), true); set_grid_placement(style, &format!("{row_start} / {row_end}"), false); } @@ -3744,10 +3986,7 @@ fn grid_area_omitted_side(start: &str) -> &str { } fn is_grid_custom_ident(value: &str) -> bool { - matches!( - parse_grid_line_kind(value), - Some(GridLineKind::IdentOnly) - ) + matches!(parse_grid_line_kind(value), Some(GridLineKind::IdentOnly)) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -3924,12 +4163,7 @@ fn set_grid_placement(style: &mut LayoutStyle, value: &str, is_col: bool) { /// `.layout > * { grid-column-end: span 4 }` and override only the start/end /// on selected children; dropping these longhands traps every item in one /// auto-placed track. -fn set_grid_placement_side( - style: &mut LayoutStyle, - value: &str, - is_col: bool, - is_start: bool, -) { +fn set_grid_placement_side(style: &mut LayoutStyle, value: &str, is_col: bool, is_start: bool) { if grid_line_has_name(value) { let raw_slot = if is_col { &mut style.grid_column_raw @@ -4069,10 +4303,8 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { { return None; } - let light = - parse_color_for_scheme(arguments[0].trim(), dark_scheme)?; - let dark = - parse_color_for_scheme(arguments[1].trim(), dark_scheme)?; + let light = parse_color_for_scheme(arguments[0].trim(), dark_scheme)?; + let dark = parse_color_for_scheme(arguments[1].trim(), dark_scheme)?; return Some(if dark_scheme { dark } else { light }); } // CSS custom property with a fallback: var(--name, ). We cannot @@ -4090,20 +4322,28 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { .or_else(|| lower_full.strip_prefix("rgba(")) { let inner = rest.strip_suffix(')').unwrap_or(rest); - let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect(); + let parts: Vec<&str> = inner + .split([',', '/', ' ']) + .filter(|p| !p.trim().is_empty()) + .collect(); if parts.len() >= 3 { let c = |s: &str| -> Option { let s = s.trim(); if let Some(pct) = s.strip_suffix('%') { - pct.parse::().ok().map(|v| (v * 2.55).round().clamp(0.0, 255.0) as u8) + pct.parse::() + .ok() + .map(|v| (v * 2.55).round().clamp(0.0, 255.0) as u8) } else { - s.parse::().ok().map(|v| v.round().clamp(0.0, 255.0) as u8) + s.parse::() + .ok() + .map(|v| v.round().clamp(0.0, 255.0) as u8) } }; let r = c(parts[0])?; let g = c(parts[1])?; let b = c(parts[2])?; - let a = parts.get(3) + let a = parts + .get(3) .and_then(|s| s.trim().parse::().ok()) .map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8) .unwrap_or(255); @@ -4112,16 +4352,34 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { return None; } // hsl()/hsla() functional notation. - if let Some(rest) = lower_full.strip_prefix("hsl(").or_else(|| lower_full.strip_prefix("hsla(")) { + if let Some(rest) = lower_full + .strip_prefix("hsl(") + .or_else(|| lower_full.strip_prefix("hsla(")) + { let inner = rest.strip_suffix(')').unwrap_or(rest); - let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect(); + let parts: Vec<&str> = inner + .split([',', '/', ' ']) + .filter(|p| !p.trim().is_empty()) + .collect(); if parts.len() >= 3 { - let h = parts[0].trim().trim_end_matches("deg").parse::().ok()?; + let h = parts[0] + .trim() + .trim_end_matches("deg") + .parse::() + .ok()?; let s = parts[1].trim().trim_end_matches('%').parse::().ok()? / 100.0; let l = parts[2].trim().trim_end_matches('%').parse::().ok()? / 100.0; - let a = parts.get(3) + let a = parts + .get(3) .and_then(|s| s.trim().trim_end_matches('%').parse::().ok()) - .map(|v| if parts[3].contains('%') { (v * 2.55).round() } else { (v * 255.0).round() }.clamp(0.0, 255.0) as u8) + .map(|v| { + if parts[3].contains('%') { + (v * 2.55).round() + } else { + (v * 255.0).round() + } + .clamp(0.0, 255.0) as u8 + }) .unwrap_or(255); return Some(hsl_to_rgba(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0), a)); } @@ -4137,11 +4395,16 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { Some((m, a)) => (m, Some(a)), None => (inner, None), }; - let comps: Vec<&str> = main.split([',', ' ']).filter(|p| !p.trim().is_empty()).collect(); + let comps: Vec<&str> = main + .split([',', ' ']) + .filter(|p| !p.trim().is_empty()) + .collect(); if comps.len() >= 3 { let num = |s: &str| -> Option { let s = s.trim(); - s.strip_suffix('%').map(|p| p.parse::().map(|v| v / 100.0)).unwrap_or_else(|| s.parse::()) + s.strip_suffix('%') + .map(|p| p.parse::().map(|v| v / 100.0)) + .unwrap_or_else(|| s.parse::()) .ok() }; let l = num(comps[0])?; @@ -4149,24 +4412,38 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { let a = alpha .and_then(|s| { let s = s.trim(); - s.strip_suffix('%').map(|p| p.parse::().map(|v| v / 100.0)).unwrap_or_else(|| s.parse::()).ok() + s.strip_suffix('%') + .map(|p| p.parse::().map(|v| v / 100.0)) + .unwrap_or_else(|| s.parse::()) + .ok() }) .unwrap_or(1.0); let (oa, ob) = if is_lch { - let h = comps[2].trim().trim_end_matches("deg").parse::().ok()?; + let h = comps[2] + .trim() + .trim_end_matches("deg") + .parse::() + .ok()?; let hr = h.to_radians(); (c * hr.cos(), c * hr.sin()) } else { (c, comps[2].trim().parse::().ok()?) }; - return Some(oklab_to_rgba(l, oa, ob, (a * 255.0).round().clamp(0.0, 255.0) as u8)); + return Some(oklab_to_rgba( + l, + oa, + ob, + (a * 255.0).round().clamp(0.0, 255.0) as u8, + )); } return None; } // color-mix(in , c1 p1%, c2 p2%) - Tailwind v4 uses this pervasively, // usually `color-mix(in oklab, N%, transparent)` to apply opacity. if lower_full.starts_with("color-mix(") { - let inner = raw[raw.to_ascii_lowercase().find("color-mix(").unwrap() + "color-mix(".len()..].trim_end(); + let inner = raw + [raw.to_ascii_lowercase().find("color-mix(").unwrap() + "color-mix(".len()..] + .trim_end(); let inner = inner.strip_suffix(')').unwrap_or(inner); let args = split_top_commas(inner); if args.len() >= 3 { @@ -4176,7 +4453,7 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { let tail = s[idx + 1..].trim(); if let Some(p) = tail.strip_suffix('%').and_then(|x| x.parse::().ok()) { return parse_color_for_scheme(s[..idx].trim(), dark_scheme) - .map(|c| (c, Some(p / 100.0))); + .map(|c| (c, Some(p / 100.0))); } } parse_color_for_scheme(s, dark_scheme).map(|c| (c, None)) @@ -4193,12 +4470,26 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { // Mixing with a fully transparent color is the opacity idiom: // keep the visible color, scale its alpha (not toward black). if c2[3] == 0 { - return Some([c1[0], c1[1], c1[2], (c1[3] as f32 * w1).round().clamp(0.0, 255.0) as u8]); + return Some([ + c1[0], + c1[1], + c1[2], + (c1[3] as f32 * w1).round().clamp(0.0, 255.0) as u8, + ]); } if c1[3] == 0 { - return Some([c2[0], c2[1], c2[2], (c2[3] as f32 * w2).round().clamp(0.0, 255.0) as u8]); + return Some([ + c2[0], + c2[1], + c2[2], + (c2[3] as f32 * w2).round().clamp(0.0, 255.0) as u8, + ]); } - let m = |i: usize| (c1[i] as f32 * w1 + c2[i] as f32 * w2).round().clamp(0.0, 255.0) as u8; + let m = |i: usize| { + (c1[i] as f32 * w1 + c2[i] as f32 * w2) + .round() + .clamp(0.0, 255.0) as u8 + }; return Some([m(0), m(1), m(2), m(3)]); } } @@ -4266,9 +4557,7 @@ fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> { fn resolve_svg_presentation_color(value: &str, dark_scheme: bool) -> String { let raw = value.trim(); if raw.to_ascii_lowercase().contains("light-dark(") { - if let Some([red, green, blue, alpha]) = - parse_color_for_scheme(raw, dark_scheme) - { + if let Some([red, green, blue, alpha]) = parse_color_for_scheme(raw, dark_scheme) { return format!("#{red:02x}{green:02x}{blue:02x}{alpha:02x}"); } } @@ -4447,7 +4736,11 @@ fn oklab_to_rgba(l: f32, a: f32, b: f32, alpha: u8) -> [u8; 4] { let lb = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; let enc = |x: f32| { let x = x.clamp(0.0, 1.0); - let s = if x <= 0.0031308 { 12.92 * x } else { 1.055 * x.powf(1.0 / 2.4) - 0.055 }; + let s = if x <= 0.0031308 { + 12.92 * x + } else { + 1.055 * x.powf(1.0 / 2.4) - 0.055 + }; (s * 255.0).round().clamp(0.0, 255.0) as u8 }; [enc(lr), enc(lg), enc(lb), alpha] @@ -4496,7 +4789,12 @@ fn hsl_to_rgba(h: f32, s: f32, l: f32, a: u8) -> [u8; 4] { } #[derive(Clone, Copy)] -enum Side { Top, Right, Bottom, Left } +enum Side { + Top, + Right, + Bottom, + Left, +} fn border_style(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { @@ -4530,7 +4828,10 @@ fn border_width(value: &str) -> Option { "thick" => 5.0, _ => strict_border_length(value)?, }; - width.is_finite().then_some(width).filter(|width| *width >= 0.0) + width + .is_finite() + .then_some(width) + .filter(|width| *width >= 0.0) } fn strict_border_length(value: &str) -> Option { @@ -4547,7 +4848,10 @@ fn strict_border_length(value: &str) -> Option { } const UNITS: [&str; 5] = ["rem", "px", "pt", "em", "ex"]; let unit = UNITS.iter().find(|unit| lower.ends_with(**unit))?; - let number = lower[..lower.len() - unit.len()].trim().parse::().ok()?; + let number = lower[..lower.len() - unit.len()] + .trim() + .parse::() + .ok()?; number.is_finite().then(|| px(value)).flatten() } @@ -4578,10 +4882,7 @@ fn side_widths_mut(model: &mut crate::BorderModel, side: Side) -> &mut f32 { } } -fn side_styles_mut( - model: &mut crate::BorderModel, - side: Side, -) -> &mut crate::BorderStyle { +fn side_styles_mut(model: &mut crate::BorderModel, side: Side) -> &mut crate::BorderStyle { match side { Side::Top => &mut model.styles.top, Side::Right => &mut model.styles.right, @@ -4590,10 +4891,7 @@ fn side_styles_mut( } } -fn side_colors_mut( - model: &mut crate::BorderModel, - side: Side, -) -> &mut Option<[u8; 4]> { +fn side_colors_mut(model: &mut crate::BorderModel, side: Side) -> &mut Option<[u8; 4]> { match side { Side::Top => &mut model.colors.top, Side::Right => &mut model.colors.right, @@ -4607,8 +4905,7 @@ fn apply_border_widths(style: &mut LayoutStyle, value: &str) { value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer" ) { - style.border_model.specified_widths = - crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH); + style.border_model.specified_widths = crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH); sync_used_border(style); return; } @@ -4677,10 +4974,7 @@ fn set_border_style(style: &mut LayoutStyle, side: Side, value: &str) { } } -fn parse_border_colors( - value: &str, - dark_scheme: bool, -) -> Option>> { +fn parse_border_colors(value: &str, dark_scheme: bool) -> Option>> { let tokens = split_ws_paren(value); let values = tokens .iter() @@ -4735,7 +5029,10 @@ struct BorderShorthand { fn parse_border_shorthand(value: &str, dark_scheme: bool) -> Option { let lower = value.trim().to_ascii_lowercase(); - if matches!(lower.as_str(), "initial" | "unset" | "revert" | "revert-layer") { + if matches!( + lower.as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { return Some(BorderShorthand { width: crate::border::MEDIUM_BORDER_WIDTH, style: crate::BorderStyle::None, @@ -4834,10 +5131,22 @@ fn parsed_border_radii(value: &str) -> Option { horizontal }; Some(crate::BorderRadii { - top_left: crate::CornerRadius { x: horizontal.top, y: vertical.top }, - top_right: crate::CornerRadius { x: horizontal.right, y: vertical.right }, - bottom_right: crate::CornerRadius { x: horizontal.bottom, y: vertical.bottom }, - bottom_left: crate::CornerRadius { x: horizontal.left, y: vertical.left }, + top_left: crate::CornerRadius { + x: horizontal.top, + y: vertical.top, + }, + top_right: crate::CornerRadius { + x: horizontal.right, + y: vertical.right, + }, + bottom_right: crate::CornerRadius { + x: horizontal.bottom, + y: vertical.bottom, + }, + bottom_left: crate::CornerRadius { + x: horizontal.left, + y: vertical.left, + }, }) } @@ -4862,9 +5171,9 @@ fn set_corner_radius(style: &mut LayoutStyle, corner: usize, value: &str) { let tokens = split_ws_paren(value); match tokens.as_slice() { [x] => radius_value(x).map(crate::CornerRadius::circular), - [x, y] => radius_value(x).zip(radius_value(y)).map(|(x, y)| { - crate::CornerRadius { x, y } - }), + [x, y] => radius_value(x) + .zip(radius_value(y)) + .map(|(x, y)| crate::CornerRadius { x, y }), _ => None, } }; @@ -4880,7 +5189,10 @@ fn set_corner_radius(style: &mut LayoutStyle, corner: usize, value: &str) { fn parse_outline_shorthand(value: &str, dark_scheme: bool) -> Option { let lower = value.trim().to_ascii_lowercase(); - if matches!(lower.as_str(), "initial" | "unset" | "revert" | "revert-layer") { + if matches!( + lower.as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { return Some(crate::OutlineModel::default()); } if value.trim().is_empty() || lower == "inherit" { @@ -4924,7 +5236,10 @@ fn apply_outline_shorthand(style: &mut LayoutStyle, value: &str) { } fn set_outline_width(style: &mut LayoutStyle, value: &str) { - let width = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + let width = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { Some(crate::border::MEDIUM_BORDER_WIDTH) } else { border_width(value) @@ -4935,7 +5250,10 @@ fn set_outline_width(style: &mut LayoutStyle, value: &str) { } fn set_outline_style(style: &mut LayoutStyle, value: &str) { - let line_style = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + let line_style = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { Some(crate::BorderStyle::None) } else { outline_style(value) @@ -4946,7 +5264,10 @@ fn set_outline_style(style: &mut LayoutStyle, value: &str) { } fn set_outline_color(style: &mut LayoutStyle, value: &str) { - let color = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + let color = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { Some(None) } else { border_color(value, style.color_scheme_dark) @@ -4957,7 +5278,10 @@ fn set_outline_color(style: &mut LayoutStyle, value: &str) { } fn set_outline_offset(style: &mut LayoutStyle, value: &str) { - let offset = if matches!(value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer") { + let offset = if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { Some(0.0) } else { strict_border_length(value).filter(|value| value.is_finite()) @@ -5361,8 +5685,14 @@ fn eval_calc(expr: &str) -> Option { let mut depth = 0i32; for c in expr.chars() { match c { - '(' => { depth += 1; cur.push(c); } - ')' => { depth -= 1; cur.push(c); } + '(' => { + depth += 1; + cur.push(c); + } + ')' => { + depth -= 1; + cur.push(c); + } '+' | '-' if depth == 0 => { let follows_product_operator = cur .chars() @@ -5404,8 +5734,14 @@ fn eval_product(term: &str) -> Option { let mut factors: Vec<(char, String)> = Vec::new(); for c in term.chars() { match c { - '(' => { depth += 1; cur.push(c); } - ')' => { depth -= 1; cur.push(c); } + '(' => { + depth += 1; + cur.push(c); + } + ')' => { + depth -= 1; + cur.push(c); + } '*' | '/' if depth == 0 => { if cur.trim().is_empty() { return None; @@ -5667,10 +6003,7 @@ fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) { || ["rem", "em", "ex", "vw", "vh", "vmin", "vmax"] .iter() .any(|unit| lower.ends_with(unit)); - let expression = if value.eq_ignore_ascii_case("normal") - || value.is_empty() - || !contextual - { + let expression = if value.eq_ignore_ascii_case("normal") || value.is_empty() || !contextual { None } else { Some(value.to_string()) @@ -5703,8 +6036,8 @@ pub(crate) fn line_height_expression_is_length(value: &str) -> bool { || [ "px", "pt", "pc", "in", "cm", "mm", "rem", "em", "ex", "vw", "vh", "vmin", "vmax", ] - .iter() - .any(|unit| lower.contains(unit)) + .iter() + .any(|unit| lower.contains(unit)) } /// Parse the layout-relevant portion of the CSS `font` shorthand: @@ -5830,11 +6163,16 @@ fn is_font_size_token(value: &str) -> bool { return true; } [ - "px", "pt", "em", "ex", "rem", "vw", "vh", "dvw", "dvh", "svw", - "svh", "lvw", "lvh", "vmin", "vmax", "%", + "px", "pt", "em", "ex", "rem", "vw", "vh", "dvw", "dvh", "svw", "svh", "lvw", "lvh", + "vmin", "vmax", "%", ] .iter() - .any(|unit| lower.strip_suffix(unit).and_then(|number| number.parse::().ok()).is_some()) + .any(|unit| { + lower + .strip_suffix(unit) + .and_then(|number| number.parse::().ok()) + .is_some() + }) } fn dimension_value(tok: &str) -> crate::Dimension { @@ -5854,11 +6192,21 @@ fn dimension_value(tok: &str) -> crate::Dimension { return Dimension::Percent(v / 100.0); } // Order matters: check `rem` before `em`, `vmin`/`vmax` before `vw`/`vh`. - if let Some(v) = lower.strip_suffix("rem").and_then(parse) { return Dimension::Rem(v); } - if let Some(v) = lower.strip_suffix("em").and_then(parse) { return Dimension::Em(v); } - if let Some(v) = lower.strip_suffix("ex").and_then(parse) { return Dimension::Ex(v); } - if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { return Dimension::Vmin(v); } - if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { return Dimension::Vmax(v); } + if let Some(v) = lower.strip_suffix("rem").and_then(parse) { + return Dimension::Rem(v); + } + if let Some(v) = lower.strip_suffix("em").and_then(parse) { + return Dimension::Em(v); + } + if let Some(v) = lower.strip_suffix("ex").and_then(parse) { + return Dimension::Ex(v); + } + if let Some(v) = lower.strip_suffix("vmin").and_then(parse) { + return Dimension::Vmin(v); + } + if let Some(v) = lower.strip_suffix("vmax").and_then(parse) { + return Dimension::Vmax(v); + } if let Some(v) = lower .strip_suffix("dvw") .or_else(|| lower.strip_suffix("svw")) @@ -5875,10 +6223,18 @@ fn dimension_value(tok: &str) -> crate::Dimension { { return Dimension::Vh(v); } - if let Some(v) = lower.strip_suffix("vw").and_then(parse) { return Dimension::Vw(v); } - if let Some(v) = lower.strip_suffix("vh").and_then(parse) { return Dimension::Vh(v); } - if let Some(v) = lower.strip_suffix("px").and_then(parse) { return Dimension::Px(v); } - if let Some(v) = lower.strip_suffix("pt").and_then(parse) { return Dimension::Px(v * 1.333); } + if let Some(v) = lower.strip_suffix("vw").and_then(parse) { + return Dimension::Vw(v); + } + if let Some(v) = lower.strip_suffix("vh").and_then(parse) { + return Dimension::Vh(v); + } + if let Some(v) = lower.strip_suffix("px").and_then(parse) { + return Dimension::Px(v); + } + if let Some(v) = lower.strip_suffix("pt").and_then(parse) { + return Dimension::Px(v * 1.333); + } // CSS lengths accept a unitless number only when it is zero. Treating // arbitrary numbers as pixels changes invalid declarations into tiny // geometry (for example `font-size:.813` must be ignored and inherited, @@ -6120,7 +6476,7 @@ fn apply_margin_shorthand(style: &mut LayoutStyle, value: &str) { fn px_value(tok: &str) -> Option { let mut n = tok; let mut scale = 1.0; - + if n.ends_with("px") { n = &n[..n.len() - 2]; } else if n.ends_with("pt") { @@ -6138,7 +6494,7 @@ fn px_value(tok: &str) -> Option { } else { n = n.trim_end_matches(|c: char| c.is_ascii_alphabetic()); } - + if n.chars() .any(|c| !(c.is_ascii_digit() || c == '.' || c == '-')) { @@ -6246,9 +6602,17 @@ fn parse_linear_gradient(value: &str, dark_scheme: bool) -> Option { d += 1; cur.push(c); } - ')' => { d -= 1; cur.push(c); } - ',' if d == 0 => { parts.push(std::mem::take(&mut cur)); } + '(' => { + d += 1; + cur.push(c); + } + ')' => { + d -= 1; + cur.push(c); + } + ',' if d == 0 => { + parts.push(std::mem::take(&mut cur)); + } _ => cur.push(c), } } @@ -6273,9 +6637,14 @@ fn parse_linear_gradient(value: &str, dark_scheme: bool) -> Option 0.0, "to right" => 90.0, "to bottom" => 180.0, "to left" => 270.0, - "to top right" | "to right top" => 45.0, "to bottom right" | "to right bottom" => 135.0, - "to bottom left" | "to left bottom" => 225.0, "to top left" | "to left top" => 315.0, + "to top" => 0.0, + "to right" => 90.0, + "to bottom" => 180.0, + "to left" => 270.0, + "to top right" | "to right top" => 45.0, + "to bottom right" | "to right bottom" => 135.0, + "to bottom left" | "to left bottom" => 225.0, + "to top left" | "to left top" => 315.0, _ => 180.0, }; stop_start = 1; @@ -6306,7 +6675,7 @@ fn parse_linear_gradient(value: &str, dark_scheme: bool) -> Option Option { return gradians.trim().parse::().ok().map(|grad| grad * 0.9); } if let Some(radians) = value.strip_suffix("rad") { - return radians - .trim() - .parse::() - .ok() - .map(f32::to_degrees); + return radians.trim().parse::().ok().map(f32::to_degrees); } None } @@ -6499,19 +6864,13 @@ fn split_color_stop(value: &str) -> (&str, Option) { .strip_suffix('%') .and_then(|number| number.parse::().ok()) { - return ( - value[..idx].trim(), - Some((percent / 100.0).clamp(0.0, 1.0)), - ); + return (value[..idx].trim(), Some((percent / 100.0).clamp(0.0, 1.0))); } if let Some(degrees) = tail .strip_suffix("deg") .and_then(|number| number.parse::().ok()) { - return ( - value[..idx].trim(), - Some((degrees / 360.0).clamp(0.0, 1.0)), - ); + return (value[..idx].trim(), Some((degrees / 360.0).clamp(0.0, 1.0))); } } (value, None) @@ -6861,8 +7220,8 @@ fn parse_background_position(value: &str) -> crate::BackgroundPosition { // Three/four-value syntax anchors an offset to a named edge: // `right 10px bottom 20px` => `calc(100% - 10px) // calc(100% - 20px)`. - let mut x = None; - let mut y = None; + let mut x = None; + let mut y = None; let mut index = 0; while index < tokens.len() { let token = tokens[index]; @@ -6876,7 +7235,7 @@ fn parse_background_position(value: &str) -> crate::BackgroundPosition { x = Some(center); } else if y.is_none() { y = Some(center); - } + } index += 1; continue; } @@ -6894,7 +7253,7 @@ fn parse_background_position(value: &str) -> crate::BackgroundPosition { y = Some(position); } else { x = Some(position); - } + } index += usize::from(offset.is_some()); } else if let Some(position) = numeric(token) { if x.is_none() { @@ -7127,10 +7486,7 @@ mod tests { #[test] fn parses_multicol_count_shorthand_and_break_avoidance() { - let shorthand = compute_style( - "div", - Some("columns: 240px 3; break-inside: avoid-column"), - ); + let shorthand = compute_style("div", Some("columns: 240px 3; break-inside: avoid-column")); assert_eq!(shorthand.column_count, Some(3)); assert!(shorthand.break_inside_avoid); @@ -7146,16 +7502,19 @@ mod tests { ); assert_eq!(finite.animation_name.as_deref(), Some("dismiss-overlay")); assert_eq!(finite.animation_timing.duration_ms, 600.0); - assert_eq!(finite.animation_timing.fill_mode, crate::AnimationFillMode::Forwards); + assert_eq!( + finite.animation_timing.fill_mode, + crate::AnimationFillMode::Forwards + ); assert_eq!(finite.animation_timing.iteration_count, 1.0); - let infinite = compute_style( - "div", - Some("animation: pulse 1s linear infinite"), - ); + let infinite = compute_style("div", Some("animation: pulse 1s linear infinite")); assert_eq!(infinite.animation_name.as_deref(), Some("pulse")); assert_eq!(infinite.animation_timing.duration_ms, 1000.0); - assert_eq!(infinite.animation_timing.fill_mode, crate::AnimationFillMode::None); + assert_eq!( + infinite.animation_timing.fill_mode, + crate::AnimationFillMode::None + ); assert!(infinite.animation_timing.iteration_count.is_infinite()); let calculated = compute_style( @@ -7173,8 +7532,14 @@ mod tests { calculated.animation_timing.direction, crate::AnimationDirection::AlternateReverse ); - assert_eq!(calculated.animation_timing.fill_mode, crate::AnimationFillMode::Both); - assert_eq!(calculated.animation_timing.play_state, crate::AnimationPlayState::Paused); + assert_eq!( + calculated.animation_timing.fill_mode, + crate::AnimationFillMode::Both + ); + assert_eq!( + calculated.animation_timing.play_state, + crate::AnimationPlayState::Paused + ); assert_eq!(parse_animation_time_ms("calc(1s / 2 + 25ms)"), Some(525.0)); assert_eq!(parse_animation_time_ms("1s * 2"), None); @@ -7211,10 +7576,7 @@ mod tests { assert!(!inherited.internal_flex_container); assert!(!inherited.display_contents); - let important = compute_style( - "div", - Some("display:block!important;display:contents"), - ); + let important = compute_style("div", Some("display:block!important;display:contents")); assert_eq!(important.display, Display::Block); assert!(!important.display_contents); } @@ -7306,10 +7668,7 @@ mod tests { crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH) ); - let side = compute_style( - "div", - Some("border:3px solid red;border-left:none"), - ); + let side = compute_style("div", Some("border:3px solid red;border-left:none")); assert_eq!(side.border.top, 3.0); assert_eq!(side.border.right, 3.0); assert_eq!(side.border.bottom, 3.0); @@ -7347,10 +7706,27 @@ mod tests { border-radius:12px;border-radius:10px/", ), ); - assert_eq!(style.border, Edges { top: 4.0, right: 4.0, bottom: 4.0, left: 4.0 }); - assert_eq!(style.border_model.styles, crate::Sides::all(crate::BorderStyle::Dashed)); - assert_eq!(style.border_model.colors, crate::Sides::all(Some([255, 0, 0, 255]))); - assert_eq!(style.border_model.radii.top_left.x, crate::RadiusValue::pixels(12.0)); + assert_eq!( + style.border, + Edges { + top: 4.0, + right: 4.0, + bottom: 4.0, + left: 4.0 + } + ); + assert_eq!( + style.border_model.styles, + crate::Sides::all(crate::BorderStyle::Dashed) + ); + assert_eq!( + style.border_model.colors, + crate::Sides::all(Some([255, 0, 0, 255])) + ); + assert_eq!( + style.border_model.radii.top_left.x, + crate::RadiusValue::pixels(12.0) + ); assert!(!supports_declaration("border-width", "10%")); assert!(!supports_declaration("border-width", "4")); } @@ -7364,8 +7740,19 @@ mod tests { outline:8px dotted blue;outline:green", ), ); - assert_eq!(style.border, Edges { top: 3.0, right: 3.0, bottom: 3.0, left: 3.0 }); - assert_eq!(style.border_model.styles, crate::Sides::all(crate::BorderStyle::Solid)); + assert_eq!( + style.border, + Edges { + top: 3.0, + right: 3.0, + bottom: 3.0, + left: 3.0 + } + ); + assert_eq!( + style.border_model.styles, + crate::Sides::all(crate::BorderStyle::Solid) + ); assert_eq!(style.border_model.colors, crate::Sides::all(None)); assert_eq!(style.outline.specified_width, 3.0); assert_eq!(style.outline.style, crate::BorderStyle::None); @@ -7374,10 +7761,7 @@ mod tests { #[test] fn item_self_alignment_parses_and_resets() { - let aligned = compute_style( - "div", - Some("align-self:safe center;justify-self:flex-end"), - ); + let aligned = compute_style("div", Some("align-self:safe center;justify-self:flex-end")); assert_eq!(aligned.align_self, Some(taffy::AlignSelf::SAFE_CENTER)); assert_eq!(aligned.justify_self, Some(taffy::JustifySelf::FLEX_END)); @@ -7407,7 +7791,10 @@ mod tests { "div", Some("align-content:space-between;place-content:safe center end"), ); - assert_eq!(content.align_content, Some(taffy::AlignContent::SAFE_CENTER)); + assert_eq!( + content.align_content, + Some(taffy::AlignContent::SAFE_CENTER) + ); assert_eq!(content.justify_content, Some(taffy::JustifyContent::END)); } @@ -7424,7 +7811,10 @@ mod tests { assert_eq!(s.line_height, Some(crate::LineHeight::Px(60.0))); assert_eq!(s.font_weight.as_deref(), Some("500")); assert_eq!(s.font_style_italic, Some(false)); - assert_eq!(s.font_family.as_deref(), Some("\"google sans\", sans-serif")); + assert_eq!( + s.font_family.as_deref(), + Some("\"google sans\", sans-serif") + ); let reset = compute_style( "div", @@ -7462,7 +7852,10 @@ mod tests { fn font_weight_css_wide_keywords_override_heading_ua_weight() { let inherited = compute_style("h1", Some("font-weight:inherit")); assert_eq!(inherited.font_weight.as_deref(), Some("inherit")); - assert_eq!(computed_font_weight(inherited.font_weight.as_deref(), 500), 500); + assert_eq!( + computed_font_weight(inherited.font_weight.as_deref(), 500), + 500 + ); let unset = compute_style("h1", Some("font-weight:unset")); assert_eq!(unset.font_weight.as_deref(), Some("inherit")); @@ -7470,7 +7863,10 @@ mod tests { let initial = compute_style("h1", Some("font-weight:initial")); assert_eq!(initial.font_weight.as_deref(), Some("400")); - assert_eq!(computed_font_weight(initial.font_weight.as_deref(), 500), 400); + assert_eq!( + computed_font_weight(initial.font_weight.as_deref(), 500), + 400 + ); } #[test] @@ -7532,9 +7928,8 @@ mod tests { r#""wght" 1e999"#, r#""wégt" 1"#, ] { - let css = format!( - r#"font-variation-settings:"opsz" 20;font-variation-settings:{malformed}"# - ); + let css = + format!(r#"font-variation-settings:"opsz" 20;font-variation-settings:{malformed}"#); let unchanged = compute_style("span", Some(&css)); assert_eq!( unchanged.font_variation_settings.as_deref(), @@ -7644,10 +8039,7 @@ mod tests { ); assert_eq!(relative.letter_spacing_non_normal, Some(true)); - let reset = compute_style( - "span", - Some("letter-spacing:4px;letter-spacing:normal"), - ); + let reset = compute_style("span", Some("letter-spacing:4px;letter-spacing:normal")); assert_eq!(reset.letter_spacing, Some(0.0)); assert_eq!(reset.letter_spacing_non_normal, Some(false)); @@ -7679,10 +8071,7 @@ mod tests { let initial = compute_style("p", Some("text-indent:18px;text-indent:initial")); assert_eq!(initial.text_indent, Some(crate::Dimension::Px(0.0))); - let invalid = compute_style( - "p", - Some("text-indent:18px;text-indent:10px hanging"), - ); + let invalid = compute_style("p", Some("text-indent:18px;text-indent:10px hanging")); assert_eq!(invalid.text_indent, Some(crate::Dimension::Px(18.0))); assert!(supports_declaration("text-indent", "-9999px")); assert!(supports_declaration("text-indent", "25%")); @@ -7700,7 +8089,10 @@ mod tests { assert!(supports_declaration("-webkit-line-clamp", value), "{value}"); } for value in ["0", "-1", "1.5", "2px", ""] { - assert!(!supports_declaration("-webkit-line-clamp", value), "{value}"); + assert!( + !supports_declaration("-webkit-line-clamp", value), + "{value}" + ); } for value in ["horizontal", "vertical", "inline-axis", "block-axis"] { assert!(supports_declaration("-webkit-box-orient", value), "{value}"); @@ -7715,10 +8107,7 @@ mod tests { assert_eq!(invalid.text_overflow, crate::TextOverflow::Ellipsis); assert_eq!(invalid.webkit_line_clamp, Some(2)); - let saturated = compute_style( - "div", - Some("-webkit-line-clamp:999999999999999999999"), - ); + let saturated = compute_style("div", Some("-webkit-line-clamp:999999999999999999999")); assert_eq!(saturated.webkit_line_clamp, Some(i32::MAX as u32)); } @@ -7859,10 +8248,7 @@ mod tests { let name_only = compute_style("div", Some("container-type:size;container:card")); assert_eq!(name_only.container_type, crate::ContainerType::Normal); - let inherited = compute_style( - "div", - Some("container:outer/size;container:inherit"), - ); + let inherited = compute_style("div", Some("container:outer/size;container:inherit")); assert!(inherited.container_type_inherit); assert!(inherited.container_names_inherit); let reset = compute_style( @@ -7893,7 +8279,15 @@ mod tests { #[test] fn margin_shorthand_expands() { let s = compute_style("div", Some("margin: 10px 20px")); - assert_eq!(s.margin, Edges { top: 10.0, right: 20.0, bottom: 10.0, left: 20.0 }); + assert_eq!( + s.margin, + Edges { + top: 10.0, + right: 20.0, + bottom: 10.0, + left: 20.0 + } + ); } #[test] @@ -7915,7 +8309,10 @@ mod tests { left: 1.0, } ); - assert_eq!(style.border_model.colors, crate::Sides::all(Some([208, 217, 251, 102]))); + assert_eq!( + style.border_model.colors, + crate::Sides::all(Some([208, 217, 251, 102])) + ); let asymmetric = compute_style( "div", @@ -8000,6 +8397,67 @@ mod tests { assert!(supports_declaration("inset-block-end", "2rem")); } + #[test] + fn logical_sizes_share_horizontal_physical_size_state() { + let logical_last = compute_style( + "div", + Some( + "width:11px;inline-size:12px;\ + height:21px;block-size:22px;\ + min-width:31px;min-inline-size:32px;\ + min-height:41px;min-block-size:42px;\ + max-width:51px;max-inline-size:52px;\ + max-height:61px;max-block-size:62px", + ), + ); + assert_eq!(logical_last.width, crate::Dimension::Px(12.0)); + assert_eq!(logical_last.height, crate::Dimension::Px(22.0)); + assert_eq!(logical_last.min_width, crate::Dimension::Px(32.0)); + assert_eq!(logical_last.min_height, crate::Dimension::Px(42.0)); + assert_eq!(logical_last.max_width, crate::Dimension::Px(52.0)); + assert_eq!(logical_last.max_height, crate::Dimension::Px(62.0)); + + let physical_last = compute_style( + "div", + Some("inline-size:70px;width:71px;block-size:80px;height:81px"), + ); + assert_eq!(physical_last.width, crate::Dimension::Px(71.0)); + assert_eq!(physical_last.height, crate::Dimension::Px(81.0)); + + let deferred = compute_style( + "div", + Some( + "inline-size:calc(50vw - 10px);\ + block-size:20vh;\ + min-inline-size:2rem;\ + max-block-size:calc(100vh - 5px)", + ), + ); + assert_eq!( + deferred.size_expressions[0].as_deref(), + Some("calc(50vw - 10px)") + ); + assert_eq!(deferred.height, crate::Dimension::Vh(20.0)); + assert_eq!(deferred.min_width, crate::Dimension::Rem(2.0)); + assert_eq!( + deferred.size_expressions[5].as_deref(), + Some("calc(100vh - 5px)") + ); + + for name in [ + "inline-size", + "block-size", + "min-inline-size", + "min-block-size", + "max-inline-size", + "max-block-size", + ] { + assert!(supports_declaration(name, "10px"), "{name}"); + } + assert!(supports_declaration("inline-size", "fit-content")); + assert!(!supports_declaration("inline-size", "definitely-invalid")); + } + #[test] fn counter_properties_parse_ordered_name_integer_pairs() { let style = compute_style( @@ -8049,10 +8507,7 @@ mod tests { )] )); - let shorthand = compute_style( - "div", - Some("display:grid;grid:auto-flow/repeat(3,1fr)"), - ); + let shorthand = compute_style("div", Some("display:grid;grid:auto-flow/repeat(3,1fr)")); assert_eq!(shorthand.grid_auto_flow, Some(taffy::GridAutoFlow::Row)); assert_eq!(shorthand.grid_template_columns.len(), 3); } @@ -8068,7 +8523,10 @@ mod tests { ); assert_eq!(tracks.grid_auto_columns.len(), 2); assert_eq!(tracks.grid_auto_rows.len(), 2); - assert!(supports_declaration("grid-auto-columns", "50px minmax(20px,1fr)")); + assert!(supports_declaration( + "grid-auto-columns", + "50px minmax(20px,1fr)" + )); assert!(supports_declaration("grid-auto-rows", "min-content 25%")); assert!(!supports_declaration("grid-auto-columns", "repeat(2,50px)")); @@ -8140,10 +8598,7 @@ mod tests { ); assert_eq!(named.grid_column, None); - let reset = compute_style( - "div", - Some("grid-column:2 / span 4;grid-column:initial"), - ); + let reset = compute_style("div", Some("grid-column:2 / span 4;grid-column:initial")); assert_eq!( reset.grid_column, Some(taffy::Line { @@ -8202,10 +8657,7 @@ mod tests { line.grid_area_name, None, "{value} is a grid line, not an ident-only named area" ); - assert_eq!( - line.grid_row_raw.as_deref(), - Some(expected_row.as_str()) - ); + assert_eq!(line.grid_row_raw.as_deref(), Some(expected_row.as_str())); assert_eq!( line.grid_column, Some(taffy::Line { @@ -8263,7 +8715,15 @@ mod tests { }) ); - for invalid in ["0", "span", "span 0", "span -1", "foo bar", "1 / inherit", "1.5"] { + for invalid in [ + "0", + "span", + "span 0", + "span -1", + "foo bar", + "1 / inherit", + "1.5", + ] { let style = compute_style( "div", Some(&format!("grid-area:7 / 8 / 9 / 10;grid-area:{invalid}")), @@ -8375,7 +8835,10 @@ mod tests { #[test] fn background_clip_text_flag() { - let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text")); + let s = compute_style( + "h1", + Some("color: transparent; -webkit-background-clip: text"), + ); assert!(s.background_clip_text); let vendor_fill = compute_style( "h1", @@ -8426,7 +8889,10 @@ mod tests { ); assert_eq!(style.color, Some([0x12, 0x34, 0x56, 255])); assert_eq!(style.background_color, Some([1, 2, 3, 255])); - assert_eq!(style.border_model.colors, crate::Sides::all(Some([255, 0, 0, 255]))); + assert_eq!( + style.border_model.colors, + crate::Sides::all(Some([255, 0, 0, 255])) + ); assert!(supports_declaration( "color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))" @@ -8482,15 +8948,10 @@ mod tests { ); assert_eq!(cover.background_size_fit, Some(crate::ObjectFit::Cover)); let contain = compute_style("div", Some("background-size:contain")); - assert_eq!( - contain.background_size_fit, - Some(crate::ObjectFit::Contain) - ); + assert_eq!(contain.background_size_fit, Some(crate::ObjectFit::Contain)); let contextual = compute_style( "a", - Some( - "background:url(icon.svg) no-repeat 0 50% / calc(100% - 2rem) auto", - ), + Some("background:url(icon.svg) no-repeat 0 50% / calc(100% - 2rem) auto"), ); assert_eq!( contextual.background_size_expression.as_deref(), @@ -8504,21 +8965,28 @@ mod tests { "div", Some("background-origin:content-box;background-clip:padding-box"), ); - assert_eq!(longhands.background_origin, crate::BackgroundOrigin::ContentBox); + assert_eq!( + longhands.background_origin, + crate::BackgroundOrigin::ContentBox + ); assert_eq!(longhands.background_clip, crate::BackgroundClip::PaddingBox); assert!(!longhands.background_clip_text); let shorthand = compute_style( "div", - Some( - "background:linear-gradient(90deg,red,blue) content-box padding-box no-repeat", - ), + Some("background:linear-gradient(90deg,red,blue) content-box padding-box no-repeat"), + ); + assert_eq!( + shorthand.background_origin, + crate::BackgroundOrigin::ContentBox ); - assert_eq!(shorthand.background_origin, crate::BackgroundOrigin::ContentBox); assert_eq!(shorthand.background_clip, crate::BackgroundClip::PaddingBox); let one_box = compute_style("div", Some("background:red content-box")); - assert_eq!(one_box.background_origin, crate::BackgroundOrigin::ContentBox); + assert_eq!( + one_box.background_origin, + crate::BackgroundOrigin::ContentBox + ); assert_eq!(one_box.background_clip, crate::BackgroundClip::ContentBox); let text = compute_style( @@ -8699,8 +9167,7 @@ mod tests { assert!(!reset.overflow_hidden); assert!(!reset.overflow_scroll_container); - let longhand_reset = - compute_style("div", Some("overflow:hidden;overflow-x:initial")); + let longhand_reset = compute_style("div", Some("overflow:hidden;overflow-x:initial")); assert!( longhand_reset.overflow_scroll_x && longhand_reset.overflow_scroll_y, "visible/hidden must recompute to auto/hidden" @@ -8736,11 +9203,7 @@ mod tests { crate::BoxSizing::BorderBox ); assert_eq!( - compute_style( - "div", - Some("box-sizing:border-box;box-sizing:content-box") - ) - .box_sizing, + compute_style("div", Some("box-sizing:border-box;box-sizing:content-box")).box_sizing, crate::BoxSizing::ContentBox ); assert_eq!(