diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index e617e3b..e82e39c 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1790,6 +1790,13 @@ impl Page { // 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()); + if let Some(js) = &self.js { + if let Some(png) = js.screenshot_prepared(viewport, base_url) { + return Some(png); + } + } + // Compatibility path for a page without a JS runtime or an ad-hoc + // viewport/base that does not match the runtime's CSSOM render key. let scroll = self .js .as_ref() diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 81ad657..5213ae9 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -64,14 +64,6 @@ pub struct JsNetworkEvent { pub timestamp: f64, } -#[cfg(feature = "render")] -pub struct LayoutCache { - rects: std::collections::HashMap, - viewport_fixed: std::collections::HashSet, - sticky: obscura_render::StickyLayout, - content_size: (f32, f32), -} - pub struct ObscuraState { pub dom: Option, pub url: String, @@ -111,11 +103,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, - /// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The - /// cache is computed on first geometry read and cleared on navigation, - /// viewport changes, and DOM/style mutations that can affect layout. + /// 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")] - pub layout_cache: Option, + pub prepared_render: Option, + /// Page-lifetime raw image/font bytes. A new document resets this cache; + /// relayout of the same document reuses it without refetching. + #[cfg(feature = "render")] + pub render_resources: obscura_render::RenderResourceCache, #[cfg(feature = "render")] pub viewport: (f32, f32), /// Root scrolling offset in CSS pixels. With render enabled this is @@ -149,7 +144,9 @@ impl ObscuraState { fetched_urls: Vec::new(), js_network_events: Vec::new(), #[cfg(feature = "render")] - layout_cache: None, + prepared_render: None, + #[cfg(feature = "render")] + render_resources: obscura_render::RenderResourceCache::default(), #[cfg(feature = "render")] viewport: (1280.0, 720.0), #[cfg(feature = "render")] @@ -210,7 +207,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str // seemingly non-geometric attribute may change computed layout. // Detached-node creation and every read-only op deliberately skip this // invalidation; the next geometry/scroll read rebuilds at most once. - shared.borrow_mut().layout_cache = None; + shared.borrow_mut().prepared_render = None; } let gs = shared.borrow(); let dom = match &gs.dom { @@ -2055,49 +2052,52 @@ pub fn build_extension() -> Extension { } #[cfg(feature = "render")] -fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> { - if state.layout_cache.is_none() { - let dom = state.dom.as_ref()?; - let laid = obscura_render::layout_dom(dom, state.viewport); - let viewport_fixed = laid.viewport_fixed_nodes(dom); - let sticky = laid.root_sticky_layout(dom, state.viewport); - let content_size = laid.scrolling_content_size(dom, state.viewport); - let mut rects = laid.rects; - for (&id, rect) in &mut rects { - if let Some((tx, ty)) = laid.translates.get(&id) { - rect.x += tx; - rect.y += ty; - } - } - state.layout_cache = Some(LayoutCache { - rects, - viewport_fixed, - sticky, - content_size, - }); +pub(crate) fn document_base_url(state: &ObscuraState) -> Option { + let document_url = url::Url::parse(&state.url).ok()?; + let base_href = state.dom.as_ref().and_then(|dom| { + dom.query_selector("base[href]") + .ok() + .flatten() + .and_then(|id| { + dom.get_node(id) + .and_then(|node| node.get_attribute("href").map(str::to_string)) + }) + }); + match base_href { + Some(href) => document_url.join(&href).ok().map(|url| url.to_string()), + None => Some(document_url.to_string()), } - state.layout_cache.as_ref() +} + +#[cfg(feature = "render")] +pub(crate) fn ensure_prepared_render( + state: &mut ObscuraState, +) -> Option<&obscura_render::PreparedRender> { + let base_url = document_base_url(state); + let viewport = state.viewport; + let stale = state.prepared_render.as_ref().map_or(true, |prepared| { + prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref() + }); + if stale { + let prepared = { + let dom = state.dom.as_ref()?; + obscura_render::prepare_dom( + dom, + viewport, + base_url.as_deref(), + &mut state.render_resources, + )? + }; + state.prepared_render = Some(prepared); + } + state.prepared_render.as_ref() } #[cfg(feature = "render")] pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) { - let viewport = state.viewport; - let content_size = ensure_layout_cache(state) - .map(|cache| cache.content_size) - .unwrap_or(viewport); - let max_x = (content_size.0 - viewport.0).max(0.0); - let max_y = (content_size.1 - viewport.1).max(0.0); - let x = if requested.0.is_finite() { - requested.0.clamp(0.0, max_x) - } else { - 0.0 - }; - let y = if requested.1.is_finite() { - requested.1.clamp(0.0, max_y) - } else { - 0.0 - }; - state.scroll_offset = (x, y); + state.scroll_offset = ensure_prepared_render(state) + .map(|prepared| prepared.clamp_scroll(requested)) + .unwrap_or((0.0, 0.0)); state.scroll_offset } @@ -2116,24 +2116,14 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let nid = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); let scroll = gs.scroll_offset; - let viewport = gs.viewport; - if let Some(cache) = ensure_layout_cache(&mut gs) { - let Some(rect) = cache.rects.get(&nid) else { + if let Some(prepared) = ensure_prepared_render(&mut gs) { + let Some(rect) = prepared.viewport_rect(nid, scroll) else { return String::new(); }; - let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll); - let (x, y) = if cache.viewport_fixed.contains(&nid) { - (rect.x, rect.y) - } else { - ( - rect.x + sticky_offset.0 - scroll.0, - rect.y + sticky_offset.1 - scroll.1, - ) - }; - let viewport_fixed = cache.viewport_fixed.contains(&nid); + let viewport_fixed = prepared.viewport_fixed_nodes().contains(&nid); return format!( "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}", - x, y, rect.width, rect.height, viewport_fixed + rect.x, rect.y, rect.width, rect.height, viewport_fixed ); } String::new() @@ -2149,8 +2139,8 @@ fn op_layout_metrics(state: &OpState) -> String { let shared = state.borrow::().clone(); let mut gs = shared.borrow_mut(); let viewport = gs.viewport; - let content = ensure_layout_cache(&mut gs) - .map(|cache| cache.content_size) + let content = ensure_prepared_render(&mut gs) + .map(|prepared| prepared.content_size()) .unwrap_or(viewport); format!( "{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}", diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index aeffb74..668281f 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -11,7 +11,7 @@ pub use deno_core::v8::IsolateHandle; use crate::module_loader::ObscuraModuleLoader; #[cfg(feature = "render")] -use crate::ops::clamp_scroll_offset; +use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render}; use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody}; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); @@ -192,17 +192,27 @@ impl ObscuraJsRuntime { pub fn set_dom(&self, dom: DomTree) { let mut gs = self.state.borrow_mut(); gs.dom = Some(dom); - // A new document invalidates the layout cache; it is recomputed lazily - // on the next geometry read. + // A new document owns a fresh retained scene and resource cache. #[cfg(feature = "render")] { - gs.layout_cache = None; + gs.prepared_render = None; + gs.render_resources = obscura_render::RenderResourceCache::default(); gs.scroll_offset = (0.0, 0.0); } } pub fn set_url(&self, url: &str) { - self.state.borrow_mut().url = url.to_string(); + let mut state = self.state.borrow_mut(); + if state.url != url { + state.url = url.to_string(); + #[cfg(feature = "render")] + { + // Relative resources use the document URL when no is + // present. Keep already-fetched absolute bytes, but rebuild + // candidate selection/layout against the new base. + state.prepared_render = None; + } + } } /// Set the document's character encoding (WHATWG canonical name). Backs @@ -292,7 +302,7 @@ impl ObscuraJsRuntime { { let mut state = self.state.borrow_mut(); state.viewport = (width as f32, height as f32); - state.layout_cache = None; + state.prepared_render = None; } let _ = self.runtime.execute_script( "", @@ -319,6 +329,41 @@ impl ObscuraJsRuntime { clamp_scroll_offset(&mut state, requested) } + /// Capture the live render viewport from the same prepared layout used by + /// CSSOM geometry. A mismatched ad-hoc viewport/base returns `None` so the + /// browser layer can retain its compatibility one-shot path. + #[cfg(feature = "render")] + pub fn screenshot_prepared( + &self, + viewport: (f32, f32), + base_url: Option<&str>, + ) -> Option> { + let mut state = self.state.borrow_mut(); + let effective_base = document_base_url(&state); + if viewport != state.viewport || base_url != effective_base.as_deref() { + return None; + } + ensure_prepared_render(&mut state)?; + let requested = state.scroll_offset; + let scroll = state + .prepared_render + .as_ref() + .map(|prepared| prepared.clamp_scroll(requested))?; + state.scroll_offset = scroll; + let ObscuraState { + dom, + prepared_render, + render_resources, + .. + } = &mut *state; + obscura_render::screenshot_prepared( + dom.as_ref()?, + prepared_render.as_mut()?, + render_resources, + scroll, + ) + } + /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { @@ -1056,7 +1101,13 @@ impl ObscuraJsRuntime { } } pub fn take_dom(&self) -> Option { - self.state.borrow_mut().dom.take() + let mut state = self.state.borrow_mut(); + #[cfg(feature = "render")] + { + state.prepared_render = None; + state.render_resources = obscura_render::RenderResourceCache::default(); + } + state.dom.take() } pub fn with_dom(&self, f: impl FnOnce(&DomTree) -> R) -> Option { @@ -1093,7 +1144,7 @@ impl ObscuraJsRuntime { } #[cfg(feature = "render")] { - state.layout_cache = None; + state.prepared_render = None; } true } @@ -2971,6 +3022,124 @@ mod tests { } } + #[cfg(feature = "render")] + #[test] + fn prepared_render_shares_resource_geometry_with_cssom_and_screenshots() { + let dom = parse_html( + r#" + + +
+ +
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_url("http://example.test/docs/page"); + rt.set_viewport(200.0, 100.0); + + let loads = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let loader_loads = std::sync::Arc::clone(&loads); + rt.state.borrow_mut().render_resources = + obscura_render::RenderResourceCache::with_loader(move |url: &str| { + assert_eq!(url, "http://example.test/assets/hero.svg"); + *loader_loads.lock().expect("loader count") += 1; + Some( + br##" + + "## + .to_vec(), + ) + }); + rt.run_page_init(); + + let before = rt + .evaluate( + r#" + const hero = document.getElementById("hero"); + const rect = hero.getBoundingClientRect(); + return [rect.width, rect.height, document.documentElement.scrollHeight]; + "#, + ) + .expect("initial geometry"); + let before = before.as_array().expect("initial tuple"); + assert_eq!(before[0].as_f64(), Some(160.0)); + assert_eq!(before[1].as_f64(), Some(40.0)); + let cssom_height = before[2].as_f64().expect("scroll height") as f32; + let (prepared_address, prepared_height) = { + let state = rt.state.borrow(); + let prepared = state.prepared_render.as_ref().expect("prepared by CSSOM"); + ( + prepared as *const obscura_render::PreparedRender as usize, + prepared.content_size().1, + ) + }; + assert_eq!(cssom_height, prepared_height); + assert_eq!(*loads.lock().expect("prepare load count"), 1); + + let base_url = Some("http://example.test/assets/"); + let top = rt + .screenshot_prepared((200.0, 100.0), base_url) + .expect("top screenshot"); + rt.evaluate( + "(function(){ window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; })()", + ) + .expect("scroll to bottom"); + let bottom = rt + .screenshot_prepared((200.0, 100.0), base_url) + .expect("bottom screenshot"); + let bottom_repeat = rt + .screenshot_prepared((200.0, 100.0), base_url) + .expect("repeated bottom screenshot"); + assert_ne!(top, bottom); + assert_eq!(bottom, bottom_repeat); + { + let state = rt.state.borrow(); + let prepared = state.prepared_render.as_ref().expect("retained prepared render"); + assert_eq!( + prepared as *const obscura_render::PreparedRender as usize, + prepared_address, + "screenshots must consume the CSSOM-prepared layout" + ); + assert_eq!(prepared.content_size().1, cssom_height); + } + assert_eq!(*loads.lock().expect("paint load count"), 1); + + let after = rt + .evaluate( + r#" + const hero = document.getElementById("hero"); + document.getElementById("frame").setAttribute("style", "width:80px"); + const rect = hero.getBoundingClientRect(); + return [rect.width, rect.height, document.documentElement.scrollHeight]; + "#, + ) + .expect("mutated geometry"); + let after = after.as_array().expect("mutated tuple"); + assert_eq!(after[0].as_f64(), Some(80.0)); + assert_eq!(after[1].as_f64(), Some(20.0)); + let mutated_height = after[2].as_f64().expect("mutated scroll height") as f32; + assert_eq!( + rt.state + .borrow() + .prepared_render + .as_ref() + .expect("rebuilt prepared render") + .content_size() + .1, + mutated_height + ); + rt.screenshot_prepared((200.0, 100.0), base_url) + .expect("mutated screenshot"); + assert_eq!( + *loads.lock().expect("mutation load count"), + 1, + "relayout must retain successful resource bytes" + ); + } + #[cfg(feature = "render")] #[test] fn rendered_layout_cache_is_invalidated_by_style_mutations() {