diff --git a/Cargo.lock b/Cargo.lock index 2472a3f..bdff8ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -558,6 +564,15 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -625,6 +640,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "debugid" version = "0.8.0" @@ -830,6 +851,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -861,6 +891,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -879,6 +915,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1104,6 +1152,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -1436,6 +1494,22 @@ dependencies = [ "png 0.17.16", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "2.14.0" @@ -1512,6 +1586,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1540,6 +1626,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1883,9 +1975,12 @@ dependencies = [ "cssparser", "image", "obscura-dom", + "resvg", "taffy", "tiny-skia", "ureq", + "url", + "usvg", ] [[package]] @@ -2039,6 +2134,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -2097,6 +2198,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2181,6 +2291,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -2399,6 +2515,32 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2413,6 +2555,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2487,6 +2638,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2662,6 +2831,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -2734,6 +2912,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "string_cache" @@ -2799,6 +2980,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.117" @@ -3218,6 +3409,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "tungstenite" @@ -3262,6 +3456,24 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -3274,6 +3486,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3324,6 +3554,34 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -3566,6 +3824,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "6.0.3" @@ -3962,6 +4226,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yasna" version = "0.5.2" @@ -4107,3 +4377,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index f4df49a..aa9db98 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -385,6 +385,78 @@ impl Page { None => Some(doc_url.clone()), } } + + async fn fetch_stylesheets(&mut self) { + let all_links = match &self.js { + Some(js) => { + js.with_dom(|dom| { + let link_ids = dom.query_selector_all("link[rel=\"stylesheet\"]").unwrap_or_default(); + let mut links = Vec::new(); + for lid in link_ids { + if let Some(node) = dom.get_node(lid) { + if let Some(href) = node.get_attribute("href") { + links.push(href.to_string()); + } + } + } + links + }).unwrap_or_default() + } + None => { + tracing::info!("fetch_stylesheets: no js runtime"); + return; + } + }; + + tracing::info!("fetch_stylesheets: found {} stylesheet links", all_links.len()); + + let document_base = self.resolve_base_url(); + + let mut fetch_tasks = Vec::new(); + for href in all_links { + let full_url = if href.starts_with("http://") || href.starts_with("https://") { + href.clone() + } else if let Some(base) = &document_base { + base.join(&href).map(|u| u.to_string()).unwrap_or_else(|_| href.clone()) + } else { + href.clone() + }; + tracing::info!("fetch_stylesheets: fetching {}", full_url); + + let client = self.http_client.clone(); + fetch_tasks.push(tokio::spawn(async move { + if let Ok(url) = url::Url::parse(&full_url) { + if let Ok(Ok(resp)) = tokio::time::timeout(tokio::time::Duration::from_secs(5), client.fetch(&url)).await { + if let Ok(css) = String::from_utf8(resp.body) { + return Some(css); + } + } + } + None + })); + } + + let mut all_css = String::new(); + for task in fetch_tasks { + if let Ok(Some(css)) = task.await { + tracing::info!("fetch_stylesheets: fetched {} bytes of CSS", css.len()); + all_css.push_str(&css); + all_css.push('\n'); + } + } + + if !all_css.is_empty() { + tracing::info!("fetch_stylesheets: injecting {} bytes of CSS", all_css.len()); + if let Some(js) = &mut self.js { + let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$"); + let code = format!( + "var style = document.createElement('style'); style.textContent = `{}`; document.head.appendChild(style);", + escaped_css + ); + let _ = js.execute_script("", &code); + } + } + } async fn execute_scripts(&mut self) { tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); @@ -1180,6 +1252,7 @@ impl Page { // listeners never registered, frameworks never bootstrapped, // page.click() handlers were no-ops. Now scripts run regardless // of waitUntil and DCL means "DOM parsed AND scripts executed". + self.fetch_stylesheets().await; self.execute_scripts().await; self.lifecycle = LifecycleState::DomContentLoaded; @@ -1286,7 +1359,11 @@ impl Page { /// viewport is zero-sized. #[cfg(feature = "render")] pub fn screenshot(&self, viewport: (f32, f32)) -> Option> { - self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport)).flatten() + // Needed to resolve the relative image URLs ("logo.svg") that make up + // the overwhelming majority of real markup. + let base_url = self.resolve_base_url(); + let base_url = base_url.as_ref().map(|u| u.as_str()); + self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport, base_url)).flatten() } /// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 6f85797..b4efc76 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -105,6 +105,15 @@ pub enum PseudoClass { Enabled, Disabled, Checked, + /// `:link` / `:any-link`: an ``/`` with an `href`. Extremely + /// common (`a:link { color: ... }` is the standard way sites set their + /// base link color), so treating it as unsupported silently drops the + /// whole rule from the cascade, not just the pseudo-class. + Link, + /// `:visited`. We have no browsing history, so this never matches, the + /// same fallback real browsers use when history-based styling is + /// suppressed for privacy. `:link`/unvisited styling wins by default. + Visited, } impl parser::NonTSPseudoClass for PseudoClass { @@ -138,6 +147,8 @@ impl ToCss for PseudoClass { PseudoClass::Enabled => dest.write_str(":enabled"), PseudoClass::Disabled => dest.write_str(":disabled"), PseudoClass::Checked => dest.write_str(":checked"), + PseudoClass::Link => dest.write_str(":link"), + PseudoClass::Visited => dest.write_str(":visited"), } } } @@ -188,6 +199,8 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser { "enabled" => Ok(PseudoClass::Enabled), "disabled" => Ok(PseudoClass::Disabled), "checked" => Ok(PseudoClass::Checked), + "link" | "any-link" => Ok(PseudoClass::Link), + "visited" => Ok(PseudoClass::Visited), _ => Err(cssparser::ParseError { kind: cssparser::ParseErrorKind::Custom( SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name), @@ -394,10 +407,21 @@ impl<'a> Element for DomElement<'a> { fn match_non_ts_pseudo_class( &self, - _pc: &PseudoClass, + pc: &PseudoClass, _context: &mut MatchingContext<'_, Self::Impl>, ) -> bool { - false + match pc { + PseudoClass::Link => self.is_link(), + PseudoClass::Visited => false, + // Dynamic user-interaction and form-state pseudo-classes have no + // meaning against a static DOM snapshot with no live user input. + PseudoClass::Hover + | PseudoClass::Active + | PseudoClass::Focus + | PseudoClass::Enabled + | PseudoClass::Disabled + | PseudoClass::Checked => false, + } } fn match_pseudo_element( @@ -662,6 +686,188 @@ impl DomTree { } Ok(results) } + + /// 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 + /// lookup). Returns `None` if the selector fails to parse. + /// + /// This is the primitive a stylesheet cascade builds on: compile every rule + /// once, index by key, then only test the handful of candidate rules against + /// each element instead of scanning the whole tree per rule. + pub fn compile_rule_selector(&self, selector: &str) -> Option { + let list = parse_selector(selector).ok()?; + let sel = list.slice().first()?.clone(); + let specificity = sel.specificity(); + let key = subject_key(&sel); + let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks); + Some(CompiledSelector { sel, specificity, key, hashes }) + } + + /// Does a single element match a precompiled selector? Allocates a fresh + /// match cache each call; for many matches in a row use [`DomTree::matcher`]. + pub fn element_matches(&self, nid: NodeId, compiled: &CompiledSelector) -> bool { + self.matcher().matches(self, nid, compiled) + } + + /// A reusable matcher that holds the selector caches and an ancestor bloom + /// filter, so a cascade can fast-reject most (element, rule) pairs without + /// walking the selector's combinators. Reuse one across a whole cascade + /// pass and drive [`Matcher::push_ancestor`] / [`Matcher::pop_ancestor`] as + /// you descend/ascend the tree so the filter tracks the current path. + pub fn matcher(&self) -> Matcher { + Matcher { + caches: selectors::context::SelectorCaches::default(), + ancestors: AncestorFilter::new(), + } + } +} + +/// Holds the servo selector match caches plus an incremental ancestor bloom +/// filter so a treewalk cascade can test thousands of (element, rule) pairs +/// cheaply: most rules are fast-rejected by the filter before any combinator +/// walking happens. +pub struct Matcher { + caches: selectors::context::SelectorCaches, + ancestors: AncestorFilter, +} + +impl Matcher { + /// 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 { + if !tree.with_node(nid, |n| n.is_element()).unwrap_or(false) { + return false; + } + let mut context = MatchingContext::new( + MatchingMode::Normal, + Some(self.ancestors.filter()), + &mut self.caches, + QuirksMode::NoQuirks, + NeedsSelectorFlags::No, + MatchingForInvalidation::No, + ); + let element = DomElement::new(tree, nid); + 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 + /// children. Must be paired with a matching [`Matcher::pop_ancestor`]. + pub fn push_ancestor(&mut self, tree: &DomTree, nid: NodeId) { + self.ancestors.push(tree, nid); + } + + /// Pop the hashes pushed by the most recent unmatched [`Matcher::push_ancestor`]. + pub fn pop_ancestor(&mut self) { + self.ancestors.pop(); + } +} + +/// An incremental ancestor bloom filter (the same structure browsers use to +/// fast-reject selector matches during a treewalk). `push` is called with each +/// element on the way down the tree, `pop` on the way back up, so at any point +/// the filter holds exactly the hashes of the current node's ancestor chain. +struct AncestorFilter { + filter: Box, + /// Hashes pushed per tree-depth level, so `pop` knows what to remove. + levels: Vec>, +} + +impl AncestorFilter { + fn new() -> Self { + AncestorFilter { filter: Box::default(), levels: Vec::new() } + } + + fn filter(&self) -> &selectors::bloom::BloomFilter { + &self.filter + } + + fn push(&mut self, tree: &DomTree, nid: NodeId) { + 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()); + if let Some(id) = node.get_attribute("id") { + 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()); + } + } + } + }); + self.levels.push(hashes); + } + + fn pop(&mut self) { + if let Some(hashes) = self.levels.pop() { + for h in hashes { + self.filter.remove_hash(h); + } + } + } +} + +fn push_hash(hashes: &mut Vec, filter: &mut selectors::bloom::BloomFilter, hash: u32) { + let masked = hash & selectors::bloom::BLOOM_HASH_MASK; + filter.insert_hash(masked); + hashes.push(masked); +} + +/// The rightmost simple selector used to index a rule for fast lookup. A rule is +/// only tested against elements that carry its key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SelectorKey { + Id(String), + Class(String), + Local(String), + Universal, +} + +/// A parsed selector plus its cached specificity and subject key. +pub struct CompiledSelector { + sel: parser::Selector, + specificity: u32, + key: SelectorKey, + hashes: parser::AncestorHashes, +} + +impl CompiledSelector { + pub fn key(&self) -> &SelectorKey { + &self.key + } + 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 { + use parser::Component; + let mut local: Option = None; + let mut class: Option = None; + let mut id: Option = None; + for comp in sel.iter_raw_match_order() { + match comp { + Component::Combinator(_) => break, + Component::ID(v) => id = Some(v.0.clone()), + Component::Class(v) => class = Some(v.0.clone()), + Component::LocalName(l) => local = Some(l.lower_name.0.to_string()), + _ => {} + } + } + if let Some(i) = id { + SelectorKey::Id(i) + } else if let Some(c) = class { + SelectorKey::Class(c) + } else if let Some(l) = local { + SelectorKey::Local(l) + } else { + SelectorKey::Universal + } } #[cfg(test)] diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 9ddbe9f..76f5011 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -10,7 +10,7 @@ description = "Optional scoped render layer for Obscura: layout (taffy) and, wit default = [] # Rasterize the laid-out tree to a pixmap (tiny-skia) with text (ab_glyph) and images. # Off by default; the layout core (getBoundingClientRect etc.) works without it. -paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:image", "dep:ureq", "dep:base64"] +paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"] [dependencies] taffy = { workspace = true } @@ -20,6 +20,17 @@ ab_glyph = { workspace = true, optional = true } image = { version = "0.24", default-features = false, features = ["jpeg", "png", "webp"], optional = true } ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true } base64 = { workspace = true, optional = true } +# SVG rasterization for , inline , and background-image +# SVGs, all extremely common for logos/icons. Pure Rust, CPU, no system deps, +# same deterministic-rendering guarantee as the rest of this crate. +resvg = { version = "0.47", default-features = false, features = ["text", "raster-images"], optional = true } +usvg = { version = "0.47", default-features = false, optional = true } +url = { workspace = true, optional = true } cssparser = "0.34" [lib] path = "src/lib.rs" + +[[bin]] +name = "paint_file" +path = "src/bin/paint_file.rs" +required-features = ["paint"] diff --git a/crates/obscura-render/assets/dejavu-sans.ttf b/crates/obscura-render/assets/dejavu-sans.ttf new file mode 100644 index 0000000..2fbbe69 Binary files /dev/null and b/crates/obscura-render/assets/dejavu-sans.ttf differ diff --git a/crates/obscura-render/src/bin/paint_file.rs b/crates/obscura-render/src/bin/paint_file.rs new file mode 100644 index 0000000..6a04863 --- /dev/null +++ b/crates/obscura-render/src/bin/paint_file.rs @@ -0,0 +1,20 @@ +// Offline paint harness: read an HTML file, paint it at a viewport, write PNG. +// Usage: paint_file [width] [height] [base_url] +use std::fs; +use obscura_dom::tree_sink::parse_html; +use obscura_render::screenshot_png; + +fn main() { + let args: Vec = std::env::args().collect(); + let input = args.get(1).map(|s| s.as_str()).unwrap_or("wiki_prepared.html"); + let output = args.get(2).map(|s| s.as_str()).unwrap_or("wiki_out.png"); + let w: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(1280.0); + let h: f32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(720.0); + let base_url = args.get(5).map(|s| s.as_str()); + + let html = fs::read_to_string(input).expect("read input html"); + let tree = parse_html(&html); + let png = screenshot_png(&tree, (w, h), base_url).expect("screenshot"); + fs::write(output, &png).expect("write png"); + eprintln!("wrote {} ({} bytes) at {}x{}", output, png.len(), w, h); +} diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs new file mode 100644 index 0000000..23b7d04 --- /dev/null +++ b/crates/obscura-render/src/css.rs @@ -0,0 +1,258 @@ +//! Stylesheet cascade: parse `/ mixed in, i.e. + // any ordinary paragraph or list item), where real CSS instead lays + // consecutive inline boxes out left-to-right, wrapping at the container's + // width. Approximate that inline formatting context by promoting such + // blocks to a wrapping flex row, so text and inline elements actually + // flow together on shared lines instead of each getting its own row. + if style.display == crate::Display::Block && has_inline_content(tree, id, styles) { + taffy_style.display = taffy::style::Display::Flex; + taffy_style.flex_direction = taffy::FlexDirection::Row; + taffy_style.flex_wrap = taffy::FlexWrap::Wrap; } let child_ids: Vec = tree diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index b80fa37..6fe0a03 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -13,10 +13,13 @@ use taffy::prelude::*; -mod style; +pub mod css; +pub use css::Stylesheet; + +pub mod style; pub use style::compute_style; -mod dom; +pub mod dom; pub use dom::{layout_dom, DomLayout}; #[cfg(feature = "paint")] @@ -33,6 +36,23 @@ pub struct Rect { pub height: f32, } +impl Rect { + /// The overlap of two rects, or `None` if they do not intersect (or the + /// overlap is degenerate). Used to accumulate an ancestor clip chain for + /// `overflow: hidden`. + pub fn intersect(&self, other: &Rect) -> Option { + let x0 = self.x.max(other.x); + let y0 = self.y.max(other.y); + 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 }) + } else { + None + } + } +} + /// Per-edge box values (margin / padding / border) in CSS pixels. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct Edges { @@ -49,6 +69,7 @@ pub enum Display { #[default] Block, Flex, + Grid, Inline, #[allow(dead_code)] None, @@ -68,11 +89,30 @@ pub struct LayoutStyle { pub display: Display, pub width: Dimension, pub height: Dimension, + pub min_width: Dimension, + pub min_height: Dimension, + pub max_width: Dimension, + pub max_height: Dimension, pub margin: Edges, pub padding: Edges, pub border: Edges, /// RGBA for the paint step. Parsed always (cheap), used only with `paint`. pub background_color: Option<[u8; 4]>, + /// The first `url(...)` reference from `background`/`background-image` + /// (gradients and repeat keywords in the same shorthand are ignored: we + /// paint the referenced image, not the gradient layer). + pub background_image: Option, + /// `background-size`, in px, when given as explicit length(s) (a bare + /// `10px` applies to both axes, matching how small square icons are + /// almost always sized). `None` means "fill the whole box" (our fallback + /// when the value is a keyword we don't evaluate, `cover`/`contain`, or + /// absent with an already icon-sized box like the HN vote arrow). + pub background_size: Option<(f32, f32)>, + /// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default + /// top-left; 1,0.5 = "right center"). Only meaningful alongside + /// `background_size`: without a known image size there is no leftover + /// box space to position within. + pub background_position: (f32, f32), /// Foreground (text) color for the paint step. pub color: Option<[u8; 4]>, pub border_color: Option<[u8; 4]>, @@ -80,8 +120,33 @@ pub struct LayoutStyle { pub font_weight: Option, pub align_items: Option, pub flex_direction: Option, + pub flex_wrap: Option, pub justify_content: Option, pub flex_grow: Option, + pub flex_shrink: Option, + + // CSS Grid. Tracks are stored as taffy sizing functions; `grid_areas` is the + // parsed `grid-template-areas` matrix (one Vec per row, `.` for a null cell), + // resolved to line placements on children in a later pass. + pub grid_template_columns: Vec, + pub grid_template_rows: Vec, + pub grid_areas: Option>>, + pub grid_area_name: Option, + pub grid_column: Option>, + pub grid_row: Option>, + pub column_gap: Option, + pub row_gap: Option, + + // Positioning. `position: absolute|fixed` takes the box out of normal flow. + pub position: Option, + pub inset: [Option; 4], // top, right, bottom, left + + /// `overflow`/-x/-y other than `visible`: clips this element's descendants + /// to its border box during paint. This is what makes the ubiquitous + /// "visually-hidden but accessible" pattern (a 1x1 absolutely-positioned, + /// clipped box used for skip-links and screen-reader-only labels) actually + /// invisible instead of painting its text wherever it lands. + pub overflow_hidden: bool, } /// A node in the input layout tree. `text` is carried for the paint phase; it @@ -154,15 +219,34 @@ fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect { pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { let mut s = Style::DEFAULT; + + // A block box that wants non-default cross-axis alignment (from + // text-align: center/right, the only way our engine currently sets + // align_items on a block-level element) needs a flex column to have any + // effect at all: taffy's native block algorithm (used for plain + // Display::Block) has no align-items concept whatsoever, it only ever + // places block children at the start edge. Promote such boxes to a flex + // column, matching the same column-flex approximation already used for + // elements like and
. + let promote_for_alignment = style.display == Display::Block + && matches!(style.align_items, Some(taffy::AlignItems::Center) | Some(taffy::AlignItems::FlexEnd)); + s.display = match style.display { + Display::Block if promote_for_alignment => taffy::style::Display::Flex, Display::Block => taffy::style::Display::Block, Display::Flex => taffy::style::Display::Flex, + Display::Grid => taffy::style::Display::Grid, Display::Inline => taffy::style::Display::Flex, Display::None => taffy::style::Display::None, }; + if promote_for_alignment { + s.flex_direction = taffy::FlexDirection::Column; + } if let Some(fd) = style.flex_direction { s.flex_direction = fd; - s.flex_wrap = taffy::FlexWrap::NoWrap; + } + if let Some(fw) = style.flex_wrap { + s.flex_wrap = fw; } else if style.display == Display::Inline { s.flex_direction = taffy::FlexDirection::Row; s.flex_wrap = taffy::FlexWrap::Wrap; @@ -173,6 +257,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { width: dimension(style.width), height: dimension(style.height), }; + s.min_size = taffy::Size { + width: dimension(style.min_width), + height: dimension(style.min_height), + }; + s.max_size = taffy::Size { + width: dimension(style.max_width), + height: dimension(style.max_height), + }; if let Some(ai) = style.align_items { s.align_items = Some(ai); } @@ -182,12 +274,58 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { if let Some(fg) = style.flex_grow { s.flex_grow = fg; } + if let Some(fs) = style.flex_shrink { + s.flex_shrink = fs; + } + + // Grid container tracks and gaps. + if style.display == Display::Grid { + if !style.grid_template_columns.is_empty() { + s.grid_template_columns = style.grid_template_columns.clone(); + } + if !style.grid_template_rows.is_empty() { + s.grid_template_rows = style.grid_template_rows.clone(); + } + } + let cg = style.column_gap.unwrap_or(0.0); + let rg = style.row_gap.unwrap_or(0.0); + s.gap = taffy::Size { + width: taffy::style::LengthPercentage::Length(cg), + height: taffy::style::LengthPercentage::Length(rg), + }; + + // Grid item placement (resolved from grid-area names or explicit lines). + if let Some(gc) = style.grid_column { + s.grid_column = gc; + } + if let Some(gr) = style.grid_row { + s.grid_row = gr; + } + + // Positioning. Absolute/fixed take the box out of flow. + if let Some(pos) = style.position { + s.position = pos; + s.inset = taffy::Rect { + top: inset_lpa(style.inset[0]), + right: inset_lpa(style.inset[1]), + bottom: inset_lpa(style.inset[2]), + left: inset_lpa(style.inset[3]), + }; + } + s.margin = rect_auto(style.margin); s.padding = rect_lp(style.padding); s.border = rect_lp(style.border); s } +fn inset_lpa(v: Option) -> taffy::style::LengthPercentageAuto { + match v { + Some(px) => taffy::style::LengthPercentageAuto::Length(px), + None => taffy::style::LengthPercentageAuto::Auto, + } +} + fn dimension(v: Dimension) -> taffy::style::Dimension { match v { Dimension::Px(px) => taffy::style::Dimension::Length(px), diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 2496211..9bc4ac7 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -14,13 +14,21 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); use crate::layout_dom; /// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or -/// None if the viewport is zero-sized. -pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option { +/// None if the viewport is zero-sized. `base_url`, when given, resolves the +/// relative image URLs (``) that make up the overwhelming +/// majority of real-world markup; without it only absolute and `data:` URLs +/// can be fetched. +pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option { let (w, h) = (viewport.0 as u32, viewport.1 as u32); let mut pixmap = Pixmap::new(w, h)?; pixmap.fill(Color::WHITE); let laid = layout_dom(tree, viewport); + // The same URL (an icon sprite, a repeated background image) commonly + // backs many elements on one page; fetch each distinct URL at most once + // per screenshot. `None` caches a failed fetch too, so a broken image + // reference does not retry on every element that references it. + let mut image_cache: std::collections::HashMap>> = std::collections::HashMap::new(); // Tree order so later elements paint over earlier ones (normal flow). for nid in tree.descendants(tree.document()) { let node = match tree.get_node(nid) { @@ -41,16 +49,29 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option { Some(r) => r, None => continue, }; - let box_rect = match Rect::from_xywh(rect.x, rect.y, rect.width, rect.height) { - Some(r) => r, - None => continue, - }; - + let style = match laid.styles.get(&nid) { Some(s) => s, None => continue, }; - + + // 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 = laid.clip_rects.get(&nid).copied().flatten(); + let visible_rect = match clip { + Some(c) => match rect.intersect(&c) { + Some(r) => r, + None => continue, + }, + None => *rect, + }; + let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) { + Some(r) => r, + None => continue, + }; + if let Some(bg) = style.background_color { let mut path = PathBuilder::new(); path.push_rect(box_rect); @@ -61,69 +82,78 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option { pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); } } - + + if let Some(bg_url) = &style.background_image { + // With an explicit background-size, paint the image at that size + // positioned within the box per background-position, instead of + // stretching it to fill the whole element: a small icon + // (background-size: 0.857em) on a wide text link must stay small + // and corner-positioned, not balloon to the link's full width. + let img_rect = match style.background_size { + Some((iw, ih)) => { + let (px, py) = style.background_position; + crate::Rect { + x: rect.x + (rect.width - iw).max(0.0) * px, + y: rect.y + (rect.height - ih).max(0.0) * py, + width: iw, + height: ih, + } + } + None => *rect, + }; + let img_rect = match clip { + Some(c) => img_rect.intersect(&c), + None => Some(img_rect), + }; + if let Some(img_rect) = img_rect { + paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache); + } + } + if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 { let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]); let mut paint = Paint::default(); paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3])); paint.anti_alias = false; - + let mut path = PathBuilder::new(); - if style.border.top > 0.0 { - if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width, style.border.top) { - path.push_rect(r); + let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| { + let edge = crate::Rect { x, y, width: w, height: h }; + let edge = match clip { Some(c) => edge.intersect(&c), None => Some(edge) }; + if let Some(e) = edge { + if let Some(r) = Rect::from_xywh(e.x, e.y, e.width, e.height) { + path.push_rect(r); + } } + }; + if style.border.top > 0.0 { + push_clipped(rect.x, rect.y, rect.width, style.border.top); } if style.border.right > 0.0 { - if let Some(r) = Rect::from_xywh(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height) { - path.push_rect(r); - } + push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height); } if style.border.bottom > 0.0 { - if let Some(r) = Rect::from_xywh(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom) { - path.push_rect(r); - } + push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom); } if style.border.left > 0.0 { - if let Some(r) = Rect::from_xywh(rect.x, rect.y, style.border.left, rect.height) { - path.push_rect(r); - } + push_clipped(rect.x, rect.y, style.border.left, rect.height); } if let Some(path) = path.finish() { pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); } } - + if name.local.as_ref() == "img" { - let src = node.get_attribute("src").unwrap_or(""); - if src.contains("y18") { - let mut paint = tiny_skia::Paint { - shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 255, 255, 255)), - ..Default::default() - }; - if let Some(r) = Rect::from_xywh(rect.x - 1.0, rect.y - 1.0, rect.width.max(18.0) + 2.0, rect.height.max(18.0) + 2.0) { - pixmap.fill_rect(r, &paint, Transform::identity(), None); + if let Some(src) = node.get_attribute("src") { + let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache); + // Fall back to alt text only when the image itself did not paint. + if !painted { + if let Some(alt) = node.get_attribute("alt") { + if !alt.is_empty() { + draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip); + } + } } - paint.shader = tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 102, 0, 255)); - if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width.max(18.0), rect.height.max(18.0)) { - pixmap.fill_rect(r, &paint, Transform::identity(), None); - draw_text(&mut pixmap, "Y", rect.x + 4.0, rect.y + 2.0, [255, 255, 255, 255], 14.0, false); - } - } else if let Some(src) = node.get_attribute("src") { - paint_image(src, &rect, &mut pixmap); - } - } - - if node.get_attribute("class").unwrap_or_default() == "votearrow" { - let mut pb = tiny_skia::PathBuilder::new(); - pb.move_to(rect.x + rect.width * 0.5, rect.y); - pb.line_to(rect.x + rect.width, rect.y + rect.height); - pb.line_to(rect.x, rect.y + rect.height); - pb.close(); - if let Some(path) = pb.finish() { - let mut paint = tiny_skia::Paint::default(); - paint.set_color_rgba8(130, 130, 130, 255); - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); } } } @@ -132,8 +162,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option { /// 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)) -> Option> { - paint_dom(tree, viewport)?.encode_png().ok() +pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option> { + paint_dom(tree, viewport, base_url)?.encode_png().ok() } fn paint_text_node( @@ -179,13 +209,9 @@ fn paint_text_node( Some(r) => r, None => return None, }; - - if text.contains("zachtronics") || text.contains("Exapunks") { - println!("DEBUG text: {:?} rect: {:?}", text, rect); - } - - // Paint text at rect.x, rect.y - draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold); + + let clip = laid.clip_rects.get(&nid).copied().flatten(); + draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip); Some(()) } @@ -202,7 +228,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 { width } -fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool) { +fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool, clip: Option) { + // A fully clipped-away run (the common "visually hidden" accessibility + // pattern: a 1x1 box with overflow: hidden) paints nothing at all. + if let Some(c) = clip { + if c.width <= 0.0 || c.height <= 0.0 { + return; + } + } let font = FontRef::try_from_slice(FONT_BYTES).unwrap(); let scale = PxScale::from(size); let scaled_font = font.as_scaled(scale); @@ -210,6 +243,7 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si 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 pixels = pixmap.pixels_mut(); let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]); @@ -225,6 +259,11 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si outlined.draw(|gx, gy, c| { 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 { + return; + } + } if px >= 0 && px < width && py >= 0 && py < height { let alpha = (a_full as f32 * c) as u8; if alpha > 0 { @@ -263,9 +302,15 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si } } -fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) { +fn paint_image( + src: &str, + base_url: Option<&str>, + rect: &crate::Rect, + pixmap: &mut Pixmap, + cache: &mut std::collections::HashMap>>, +) -> bool { if rect.width <= 0.0 || rect.height <= 0.0 { - return; + return false; } let bytes = if src.starts_with("data:image/") { @@ -273,30 +318,69 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) { use base64::Engine; base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok() } else { None } - } else if src.starts_with("http://") || src.starts_with("https://") { - ureq::get(src).call().ok().and_then(|resp| { - let mut buf = Vec::new(); - use std::io::Read; - resp.into_reader().read_to_end(&mut buf).ok()?; - Some(buf) - }) } else { - None + // Resolve relative to the document's base URL: the overwhelming + // majority of real markup uses relative image paths ("logo.svg", not + // "https://example.com/logo.svg"), so without this every relative + // silently fails to fetch, not just SVGs. + let resolved = if src.starts_with("http://") || src.starts_with("https://") { + Some(src.to_string()) + } else { + base_url + .and_then(|b| url::Url::parse(b).ok()) + .and_then(|base| base.join(src).ok()) + .map(|u| u.to_string()) + }; + match resolved { + // The same icon/sprite/background is routinely referenced by + // dozens of elements on one page (every story's vote arrow, every + // repeated logo); fetch each distinct URL over the network once + // per screenshot rather than once per element. + Some(url) => cache + .entry(url.clone()) + .or_insert_with(|| { + ureq::get(&url).call().ok().and_then(|resp| { + let mut buf = Vec::new(); + use std::io::Read; + resp.into_reader().read_to_end(&mut buf).ok()?; + Some(buf) + }) + }) + .clone(), + None => None, + } }; - if let Some(bytes) = bytes { - if let Ok(img) = image::load_from_memory(&bytes) { - let img = img.to_rgba8(); - let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle); - - let mut raw = resized.into_raw(); - for pixel in raw.chunks_exact_mut(4) { - let a = pixel[3] as u32; - pixel[0] = ((pixel[0] as u32 * a) / 255) as u8; - pixel[1] = ((pixel[1] as u32 * a) / 255) as u8; - pixel[2] = ((pixel[2] as u32 * a) / 255) as u8; - } - if let Some(img_pixmap) = Pixmap::from_vec(raw, tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32).unwrap()) { + let Some(bytes) = bytes else { return false }; + + if is_svg(&bytes) { + if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) { + pixmap.draw_pixmap( + rect.x as i32, + rect.y as i32, + svg_pixmap.as_ref(), + &tiny_skia::PixmapPaint::default(), + Transform::identity(), + None, + ); + return true; + } + return false; + } + + if let Ok(img) = image::load_from_memory(&bytes) { + let img = img.to_rgba8(); + let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle); + + let mut raw = resized.into_raw(); + for pixel in raw.chunks_exact_mut(4) { + let a = pixel[3] as u32; + pixel[0] = ((pixel[0] as u32 * a) / 255) as u8; + pixel[1] = ((pixel[1] as u32 * a) / 255) as u8; + pixel[2] = ((pixel[2] as u32 * a) / 255) as u8; + } + if let Some(size) = tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32) { + if let Some(img_pixmap) = Pixmap::from_vec(raw, size) { pixmap.draw_pixmap( rect.x as i32, rect.y as i32, @@ -305,9 +389,38 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) { Transform::identity(), None, ); + return true; } } } + false +} + +/// Sniff SVG content: either an XML/SVG prolog, or a bare ` bool { + let head = &bytes[..bytes.len().min(256)]; + let text = String::from_utf8_lossy(head); + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + trimmed.starts_with("` sizes its intrinsic content). +fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { + if width == 0 || height == 0 { + return None; + } + let opts = usvg::Options::default(); + let tree = usvg::Tree::from_data(bytes, &opts).ok()?; + let size = tree.size(); + if size.width() <= 0.0 || size.height() <= 0.0 { + 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()); + resvg::render(&tree, transform, &mut svg_pixmap.as_mut()); + Some(svg_pixmap) } #[cfg(test)] @@ -320,7 +433,7 @@ mod tests { let tree = parse_html( "
", ); - let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap"); + let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); assert_eq!(pixmap.width(), 200); // The red div is laid out at the origin; sample inside it. let inside = pixmap.pixel(10, 10).expect("pixel"); @@ -345,7 +458,7 @@ mod tests { \ ", ); - let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap"); + 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); } @@ -355,7 +468,7 @@ mod tests { let tree = parse_html( "
Hello
", ); - let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap"); + let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let mut found_green = false; for y in 0..200 { for x in 0..200 { diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 49c661a..d71bc2c 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -38,9 +38,33 @@ pub fn ua_style(tag: &str) -> LayoutStyle { style.flex_direction = Some(taffy::FlexDirection::Column); style.align_items = Some(taffy::AlignItems::Center); } else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" { - style.display = Display::None; + style.display = crate::Display::None; } else if tag == "body" { style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 }; + } else if tag == "h1" { + style.font_size = Some(32.0); + style.font_weight = Some("bold".to_string()); + style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 }; + } else if tag == "h2" { + style.font_size = Some(24.0); + style.font_weight = Some("bold".to_string()); + style.margin = Edges { top: 19.0, bottom: 19.0, left: 0.0, right: 0.0 }; + } else if tag == "h3" { + style.font_size = Some(18.7); + style.font_weight = Some("bold".to_string()); + style.margin = Edges { top: 18.0, bottom: 18.0, left: 0.0, right: 0.0 }; + } else if tag == "h4" || tag == "h5" || tag == "h6" { + style.font_size = Some(16.0); + style.font_weight = Some("bold".to_string()); + style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 }; + } else if tag == "p" || tag == "ul" || tag == "ol" { + style.margin = Edges { top: 16.0, bottom: 16.0, left: 0.0, right: 0.0 }; + } else if tag == "li" { + style.margin = Edges { top: 0.0, bottom: 0.0, left: 40.0, right: 0.0 }; + } else if tag == "b" || tag == "strong" || tag == "th" { + style.font_weight = Some("bold".to_string()); + } else if tag == "a" { + style.color = Some([0, 0, 238, 255]); // blue } else if tag == "table" || tag == "tbody" { style.display = Display::Flex; style.flex_direction = Some(taffy::FlexDirection::Column); @@ -76,14 +100,22 @@ pub fn apply_inline(style: &mut LayoutStyle, css: &str) { fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { match name { "display" => match value { - "block" => style.display = Display::Block, - "flex" => style.display = Display::Flex, - "inline" => style.display = Display::Inline, - "none" => style.display = Display::None, + "none" => style.display = crate::Display::None, + "flex" => style.display = crate::Display::Flex, + "inline-flex" => style.display = crate::Display::Flex, + "inline" => style.display = crate::Display::Inline, + "inline-block" => style.display = crate::Display::Inline, + "grid" => style.display = crate::Display::Grid, + "inline-grid" => style.display = crate::Display::Grid, + "block" => style.display = crate::Display::Block, _ => {} }, "width" => style.width = dimension_value(value), "height" => style.height = dimension_value(value), + "min-width" => style.min_width = dimension_value(value), + "min-height" => style.min_height = dimension_value(value), + "max-width" => style.max_width = dimension_value(value), + "max-height" => style.max_height = dimension_value(value), "margin" => { if let Some(e) = edges(value) { style.margin = e; } } "margin-top" => set_edge(&mut style.margin, Side::Top, px(value)), "margin-right" => set_edge(&mut style.margin, Side::Right, px(value)), @@ -108,36 +140,304 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)), "border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)), "border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)), - "background-color" | "background" => style.background_color = parse_color(value), + "background-color" => style.background_color = parse_color(value), + "background" => { + style.background_color = parse_color(value); + style.background_image = parse_url(value); + } + "background-image" => style.background_image = parse_url(value), + "background-size" => style.background_size = parse_background_size(value), + "background-position" => style.background_position = parse_background_position(value), "color" => style.color = parse_color(value), "border-color" => style.border_color = parse_color(value), "font-size" => style.font_size = px(value), "font-weight" => style.font_weight = Some(value.to_string()), - "text-align" => { - if value == "right" { - style.justify_content = Some(taffy::JustifyContent::FlexEnd); - style.align_items = Some(taffy::AlignItems::FlexEnd); - } else if value == "center" { - style.justify_content = Some(taffy::JustifyContent::Center); - style.align_items = Some(taffy::AlignItems::Center); - } + // Our engine has no real inline formatting context, so text-align is + // approximated the same way as align-items: it positions a block's + // (or column-flex's) children along the cross axis. See + // `to_taffy_style`'s block-to-flex-column promotion, which is what + // makes this actually take effect on plain block elements. + "text-align" => match value { + "right" => style.align_items = Some(taffy::AlignItems::FlexEnd), + "center" => style.align_items = Some(taffy::AlignItems::Center), + "left" | "start" | "justify" => style.align_items = Some(taffy::AlignItems::FlexStart), + _ => {} }, "align-items" => { - if value == "center" { - style.align_items = Some(taffy::AlignItems::Center); - } else if value == "flex-start" || value == "start" { - style.align_items = Some(taffy::AlignItems::FlexStart); - } else if value == "flex-end" || value == "end" { - style.align_items = Some(taffy::AlignItems::FlexEnd); + match value { + "center" => style.align_items = Some(taffy::AlignItems::Center), + "flex-start" | "start" => style.align_items = Some(taffy::AlignItems::FlexStart), + "flex-end" | "end" => style.align_items = Some(taffy::AlignItems::FlexEnd), + "stretch" => style.align_items = Some(taffy::AlignItems::Stretch), + "baseline" => style.align_items = Some(taffy::AlignItems::Baseline), + _ => {} } }, + "justify-content" => { + match value { + "center" => style.justify_content = Some(taffy::JustifyContent::Center), + "flex-start" | "start" | "left" => style.justify_content = Some(taffy::JustifyContent::FlexStart), + "flex-end" | "end" | "right" => style.justify_content = Some(taffy::JustifyContent::FlexEnd), + "space-between" => style.justify_content = Some(taffy::JustifyContent::SpaceBetween), + "space-around" => style.justify_content = Some(taffy::JustifyContent::SpaceAround), + "space-evenly" => style.justify_content = Some(taffy::JustifyContent::SpaceEvenly), + _ => {} + } + }, + "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), + _ => {} + } + }, + "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-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { style.flex_shrink = Some(v); } } + "position" => { + match value { + "absolute" | "fixed" => style.position = Some(taffy::Position::Absolute), + "relative" | "sticky" | "static" => style.position = Some(taffy::Position::Relative), + _ => {} + } + }, + "top" => style.inset[0] = px(value), + "right" => style.inset[1] = px(value), + "bottom" => style.inset[2] = px(value), + "left" => style.inset[3] = px(value), + "overflow" | "overflow-x" | "overflow-y" => { + style.overflow_hidden = value != "visible"; + } + "gap" | "grid-gap" => { + let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); + if let Some(&r) = dims.first() { style.row_gap = Some(r); style.column_gap = Some(*dims.get(1).unwrap_or(&r)); } + } + "row-gap" | "grid-row-gap" => style.row_gap = px(value), + "column-gap" | "grid-column-gap" => style.column_gap = px(value), + "grid-template-columns" => style.grid_template_columns = parse_track_list(value), + "grid-template-rows" => style.grid_template_rows = parse_track_list(value), + "grid-template-areas" => style.grid_areas = Some(parse_grid_areas(value)), + "grid-template" => parse_grid_template(style, value), + "grid-area" => { + // Named area (single ident) or line form `r/c/r/c`. We only resolve + // the named-area case here; line forms are handled by grid-row/column. + let v = value.trim(); + if !v.contains('/') && !v.is_empty() { + style.grid_area_name = Some(v.to_string()); + } + } + "grid-column" => style.grid_column = parse_grid_line(value), + "grid-row" => style.grid_row = parse_grid_line(value), _ => {} } } -/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex and a -/// small set of named colors. Returns None for anything else (transparent). -fn parse_color(value: &str) -> Option<[u8; 4]> { +/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem +/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the +/// parentheses in `minmax(...)` / `fit-content(...)`. +pub(crate) fn parse_track_list(value: &str) -> Vec { + tokenize_tracks(value).iter().map(|t| track(t)).collect() +} + +/// Split a track list on whitespace while keeping `func(a, b)` groups intact. +fn tokenize_tracks(value: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut depth = 0i32; + for c in value.chars() { + match c { + '(' => { depth += 1; cur.push(c); } + ')' => { depth -= 1; cur.push(c); } + c if c.is_whitespace() && depth == 0 => { + if !cur.is_empty() { out.push(std::mem::take(&mut cur)); } + } + c => cur.push(c), + } + } + if !cur.is_empty() { out.push(cur); } + out +} + +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((a, b)) = inner.split_once(',') { + return taffy::TrackSizingFunction::Single(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(')')) { + let lp = px_value(inner.trim()) + .map(taffy::style::LengthPercentage::Length) + .unwrap_or(taffy::style::LengthPercentage::Length(0.0)); + return taffy::TrackSizingFunction::Single(MinMax { + min: taffy::MinTrackSizingFunction::Auto, + max: taffy::MaxTrackSizingFunction::FitContent(lp), + }); + } + taffy::TrackSizingFunction::Single(MinMax { min: min_track(t), max: max_track(t) }) +} + +fn min_track(tok: &str) -> taffy::MinTrackSizingFunction { + use taffy::MinTrackSizingFunction as M; + match tok.to_ascii_lowercase().as_str() { + "min-content" => M::MinContent, + "max-content" => M::MaxContent, + "auto" => M::Auto, + other => { + if other.ends_with("fr") { + // Flexible tracks have an automatic minimum. + M::Auto + } else if let Some(px) = dim_lp(other) { + M::Fixed(px) + } else { + M::Auto + } + } + } +} + +fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction { + use taffy::MaxTrackSizingFunction as M; + let lower = tok.to_ascii_lowercase(); + match lower.as_str() { + "min-content" => M::MinContent, + "max-content" => M::MaxContent, + "auto" => M::Auto, + other => { + if let Some(fr) = other.strip_suffix("fr").and_then(|n| n.trim().parse::().ok()) { + M::Fraction(fr) + } else if let Some(px) = dim_lp(other) { + M::Fixed(px) + } else { + M::Auto + } + } + } +} + +/// A length or percentage as a taffy `LengthPercentage`. +fn dim_lp(tok: &str) -> Option { + let t = tok.trim(); + if let Some(p) = t.strip_suffix('%') { + return p.parse::().ok().map(|v| taffy::style::LengthPercentage::Percent(v / 100.0)); + } + px_value(t).map(taffy::style::LengthPercentage::Length) +} + +/// Parse `grid-template-areas: 'a a' 'b c'` into a matrix of cell names. +fn parse_grid_areas(value: &str) -> Vec> { + let mut rows = Vec::new(); + let mut in_str = false; + let mut cur = String::new(); + for c in value.chars() { + match c { + '\'' | '"' => { + if in_str { + rows.push(cur.split_whitespace().map(|s| s.to_string()).collect::>()); + cur.clear(); + in_str = false; + } else { + in_str = true; + } + } + _ if in_str => cur.push(c), + _ => {} + } + } + rows +} + +/// Parse the `grid-template: / ` shorthand. The rows side may embed +/// area strings; we support the common `tracks / tracks` form and, when area +/// strings are present, extract them too. +fn parse_grid_template(style: &mut LayoutStyle, value: &str) { + let (rows_part, cols_part) = match value.split_once('/') { + Some((r, c)) => (r.trim(), Some(c.trim())), + None => (value.trim(), None), + }; + if rows_part.contains('\'') || rows_part.contains('"') { + // Rows side carries area strings interleaved with row track sizes. + style.grid_areas = Some(parse_grid_areas(rows_part)); + } else if !rows_part.is_empty() { + style.grid_template_rows = parse_track_list(rows_part); + } + if let Some(cols) = cols_part { + style.grid_template_columns = parse_track_list(cols); + } +} + +/// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`. +fn parse_grid_line(value: &str) -> Option> { + let place = |tok: &str| -> taffy::GridPlacement { + let tok = tok.trim(); + if let Some(n) = tok.strip_prefix("span").map(|s| s.trim()) { + if let Ok(s) = n.parse::() { return taffy::style_helpers::span(s); } + } + if let Ok(i) = tok.parse::() { return taffy::style_helpers::line(i); } + taffy::GridPlacement::Auto + }; + let (a, b) = match value.split_once('/') { + Some((a, b)) => (a, Some(b)), + None => (value, None), + }; + let start = place(a); + let end = b.map(place).unwrap_or(taffy::GridPlacement::Auto); + Some(taffy::Line { start, end }) +} + +/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex, +/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named +/// colors. Returns None for anything else (transparent). +pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> { + let raw = value.trim(); + // CSS custom property with a fallback: var(--name, ). We cannot + // resolve the variable, but the fallback after the comma is a real color. + if let Some(rest) = raw.strip_prefix("var(") { + let inner = rest.strip_suffix(')').unwrap_or(rest); + if let Some((_, fallback)) = inner.split_once(',') { + return parse_color(fallback.trim()); + } + return None; + } + // rgb()/rgba() functional notation. + let lower_full = raw.to_ascii_lowercase(); + if let Some(rest) = lower_full.strip_prefix("rgb(").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(); + 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) + } else { + 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) + .and_then(|s| s.trim().parse::().ok()) + .map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8) + .unwrap_or(255); + return Some([r, g, b, a]); + } + return None; + } let v = value.split_whitespace().next()?.to_ascii_lowercase(); if let Some(h) = v.strip_prefix('#') { let (r, g, b, a) = match h.len() { @@ -173,10 +473,24 @@ fn parse_color(value: &str) -> Option<[u8; 4]> { "white" => Some([255, 255, 255, 255]), "black" => Some([0, 0, 0, 255]), "gray" | "grey" => Some([128, 128, 128, 255]), + "silver" => Some([192, 192, 192, 255]), + "lightgray" | "lightgrey" => Some([211, 211, 211, 255]), + "darkgray" | "darkgrey" => Some([169, 169, 169, 255]), + "whitesmoke" => Some([245, 245, 245, 255]), + "gainsboro" => Some([220, 220, 220, 255]), "red" => Some([255, 0, 0, 255]), "green" => Some([0, 128, 0, 255]), + "lime" => Some([0, 255, 0, 255]), "blue" => Some([0, 0, 255, 255]), + "navy" => Some([0, 0, 128, 255]), + "yellow" => Some([255, 255, 0, 255]), "orange" => Some([255, 165, 0, 255]), + "purple" => Some([128, 0, 128, 255]), + "maroon" => Some([128, 0, 0, 255]), + "teal" => Some([0, 128, 128, 255]), + "aqua" | "cyan" => Some([0, 255, 255, 255]), + "fuchsia" | "magenta" => Some([255, 0, 255, 255]), + "olive" => Some([128, 128, 0, 255]), "transparent" => Some([0, 0, 0, 0]), _ => None, } @@ -247,6 +561,67 @@ fn token(value: &str) -> Option<&str> { value.split_whitespace().next() } +/// Extract the first `url(...)` reference from a `background`/`background-image` +/// value, unquoted. Ignores any other layers in the same shorthand (gradients, +/// `no-repeat`, etc.): we paint the referenced image, not the gradient. +fn parse_url(value: &str) -> Option { + let start = value.find("url(")? + 4; + let end = value[start..].find(')')?; + let inner = value[start..start + end].trim(); + let unquoted = inner.trim_matches(|c| c == '"' || c == '\''); + if unquoted.is_empty() { + None + } else { + Some(unquoted.to_string()) + } +} + +/// `background-size: 10px` / `0.857em` / `10px 20px` -> explicit px pair. +/// Keyword values (`cover`, `contain`, `auto`) are left unhandled (`None`, the +/// "stretch to fill the box" fallback) since evaluating them needs the +/// image's own intrinsic aspect ratio, which is not known until it is +/// fetched, well after style resolution. +fn parse_background_size(value: &str) -> Option<(f32, f32)> { + let tokens: Vec<&str> = value.split_whitespace().collect(); + match tokens.as_slice() { + [one] => px_value(one).map(|v| (v, v)), + [w, h] => Some((px_value(w)?, px_value(h)?)), + _ => None, + } +} + +/// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the +/// fraction of the box's leftover space, after the image's own size, to +/// offset by: 0 = start edge, 1 = end edge, 0.5 = centered). +/// +/// `left`/`right` always set the x axis and `top`/`bottom` always set y, +/// regardless of order (CSS allows `center right` and `right center` +/// interchangeably for keyword-only values). Any axis left unset by an +/// explicit keyword defaults to centered, which correctly handles a bare +/// `center` (both axes), a single directional keyword (`right` -> the other +/// axis centers), and explicit percentages (first fills x, second fills y, +/// per CSS shorthand order). +fn parse_background_position(value: &str) -> (f32, f32) { + let mut x = None; + let mut y = None; + for tok in value.split_whitespace() { + match tok { + "left" => x = Some(0.0), + "right" => x = Some(1.0), + "top" => y = Some(0.0), + "bottom" => y = Some(1.0), + t if t.ends_with('%') => { + if let Ok(pct) = t.trim_end_matches('%').parse::() { + let v = pct / 100.0; + if x.is_none() { x = Some(v); } else { y = Some(v); } + } + } + _ => {} // "center" (or an unhandled bare length): leaves this axis to default below + } + } + (x.unwrap_or(0.5), y.unwrap_or(0.5)) +} + /// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h); /// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left). fn edges_from(dims: Vec) -> Option { diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs new file mode 100644 index 0000000..5b3a05b --- /dev/null +++ b/crates/obscura-render/tests/layout_test.rs @@ -0,0 +1,84 @@ +//! Regression test: a Hacker-News-shaped nested `` layout must come out +//! right using only the general engine (UA defaults + real CSS cascade), with +//! no per-site hardcoded selectors. This guards against reintroducing the +//! site-specific hacks that used to live in obscura-render for this exact markup. + +use obscura_dom::tree::NodeData; +use obscura_dom::tree_sink::parse_html; +use obscura_render::layout_dom; + +const HN_HTML: &str = r##" +
+ + + + + + +
+ + + + + + +
Hacker News new | pastlogin
+
+ + + + + + +
24.Exapunks (2018)
+
+"##; + +fn find_by_text<'a>( + tree: &'a obscura_dom::tree::DomTree, + layout: &'a obscura_render::DomLayout, + needle: &str, +) -> Option<(obscura_dom::tree::NodeId, &'a obscura_render::Rect)> { + for (id, rect) in &layout.rects { + if let Some(node) = tree.get_node(*id) { + if let NodeData::Text { contents } = &node.data { + if contents.trim() == needle { + return Some((*id, rect)); + } + } + } + } + None +} + +#[test] +fn hn_shaped_table_lays_out_without_site_hardcoding() { + let tree = parse_html(HN_HTML); + let layout = layout_dom(&tree, (1000.0, 1000.0)); + + // Every element got a rect; the tree isn't just being dropped. + assert!(layout.rects.len() > 10, "expected many laid-out elements, got {}", layout.rects.len()); + + // "Hacker News" (a bold link in the top bar) sits above the first + // headline row ("Exapunks (2018)"): normal top-to-bottom flow, not a + // hardcoded absolute position. + let (_, brand_rect) = find_by_text(&tree, &layout, "Hacker News").expect("brand text laid out"); + let (_, headline_rect) = find_by_text(&tree, &layout, "Exapunks (2018)").expect("headline text laid out"); + assert!( + headline_rect.y > brand_rect.y, + "headline should be below the header bar: brand.y={} headline.y={}", + brand_rect.y, + headline_rect.y + ); + + // Within the header row, "login" (right-aligned cell) sits to the right + // of "Hacker News" (left cell) — plain flex/table layout, not a magic + // per-class x-offset. + let (_, login_rect) = find_by_text(&tree, &layout, "login").expect("login text laid out"); + assert!( + login_rect.x > brand_rect.x, + "login cell should be right of the brand: brand.x={} login.x={}", + brand_rect.x, + login_rect.x + ); +}