Share prepared layout with CSSOM

This commit is contained in:
SGavrl committed 2026-07-30 22:08:01 +00:00
1 parent 6d4ce11aa1
commit 5057715358
3 files changed
+242 -76

No files matched your search

+7
View File
@@ -1790,6 +1790,13 @@ impl Page {
// the overwhelming majority of real markup. // the overwhelming majority of real markup.
let base_url = self.resolve_base_url(); let base_url = self.resolve_base_url();
let base_url = base_url.as_ref().map(|u| u.as_str()); 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 let scroll = self
.js .js
.as_ref() .as_ref()
+58 -68
View File
@@ -64,14 +64,6 @@ pub struct JsNetworkEvent {
pub timestamp: f64, pub timestamp: f64,
} }
#[cfg(feature = "render")]
pub struct LayoutCache {
rects: std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>,
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
sticky: obscura_render::StickyLayout,
content_size: (f32, f32),
}
pub struct ObscuraState { pub struct ObscuraState {
pub dom: Option<DomTree>, pub dom: Option<DomTree>,
pub url: String, pub url: String,
@@ -111,11 +103,14 @@ pub struct ObscuraState {
// drained by the Page into its network_events so the CDP layer emits // drained by the Page into its network_events so the CDP layer emits
// Network.requestWillBeSent / responseReceived for them (issue #406). // Network.requestWillBeSent / responseReceived for them (issue #406).
pub js_network_events: Vec<JsNetworkEvent>, pub js_network_events: Vec<JsNetworkEvent>,
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The /// Final image/font-aware layout shared by CSSOM geometry and screenshots.
/// cache is computed on first geometry read and cleared on navigation, /// DOM/style/viewport changes clear this value but retain resource bytes.
/// viewport changes, and DOM/style mutations that can affect layout.
#[cfg(feature = "render")] #[cfg(feature = "render")]
pub layout_cache: Option<LayoutCache>, pub prepared_render: Option<obscura_render::PreparedRender>,
/// 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")] #[cfg(feature = "render")]
pub viewport: (f32, f32), pub viewport: (f32, f32),
/// Root scrolling offset in CSS pixels. With render enabled this is /// Root scrolling offset in CSS pixels. With render enabled this is
@@ -149,7 +144,9 @@ impl ObscuraState {
fetched_urls: Vec::new(), fetched_urls: Vec::new(),
js_network_events: Vec::new(), js_network_events: Vec::new(),
#[cfg(feature = "render")] #[cfg(feature = "render")]
layout_cache: None, prepared_render: None,
#[cfg(feature = "render")]
render_resources: obscura_render::RenderResourceCache::default(),
#[cfg(feature = "render")] #[cfg(feature = "render")]
viewport: (1280.0, 720.0), viewport: (1280.0, 720.0),
#[cfg(feature = "render")] #[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. // seemingly non-geometric attribute may change computed layout.
// Detached-node creation and every read-only op deliberately skip this // Detached-node creation and every read-only op deliberately skip this
// invalidation; the next geometry/scroll read rebuilds at most once. // 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 gs = shared.borrow();
let dom = match &gs.dom { let dom = match &gs.dom {
@@ -2055,49 +2052,52 @@ pub fn build_extension() -> Extension {
} }
#[cfg(feature = "render")] #[cfg(feature = "render")]
fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> { pub(crate) fn document_base_url(state: &ObscuraState) -> Option<String> {
if state.layout_cache.is_none() { let document_url = url::Url::parse(&state.url).ok()?;
let dom = state.dom.as_ref()?; let base_href = state.dom.as_ref().and_then(|dom| {
let laid = obscura_render::layout_dom(dom, state.viewport); dom.query_selector("base[href]")
let viewport_fixed = laid.viewport_fixed_nodes(dom); .ok()
let sticky = laid.root_sticky_layout(dom, state.viewport); .flatten()
let content_size = laid.scrolling_content_size(dom, state.viewport); .and_then(|id| {
let mut rects = laid.rects; dom.get_node(id)
for (&id, rect) in &mut rects { .and_then(|node| node.get_attribute("href").map(str::to_string))
if let Some((tx, ty)) = laid.translates.get(&id) { })
rect.x += tx; });
rect.y += ty; match base_href {
} Some(href) => document_url.join(&href).ok().map(|url| url.to_string()),
} None => Some(document_url.to_string()),
state.layout_cache = Some(LayoutCache {
rects,
viewport_fixed,
sticky,
content_size,
});
} }
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")] #[cfg(feature = "render")]
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) { pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
let viewport = state.viewport; state.scroll_offset = ensure_prepared_render(state)
let content_size = ensure_layout_cache(state) .map(|prepared| prepared.clamp_scroll(requested))
.map(|cache| cache.content_size) .unwrap_or((0.0, 0.0));
.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 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 nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut(); let mut gs = shared.borrow_mut();
let scroll = gs.scroll_offset; let scroll = gs.scroll_offset;
let viewport = gs.viewport; if let Some(prepared) = ensure_prepared_render(&mut gs) {
if let Some(cache) = ensure_layout_cache(&mut gs) { let Some(rect) = prepared.viewport_rect(nid, scroll) else {
let Some(rect) = cache.rects.get(&nid) else {
return String::new(); return String::new();
}; };
let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll); let viewport_fixed = prepared.viewport_fixed_nodes().contains(&nid);
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);
return format!( return format!(
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}", "{{\"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() String::new()
@@ -2149,8 +2139,8 @@ fn op_layout_metrics(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone(); let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut(); let mut gs = shared.borrow_mut();
let viewport = gs.viewport; let viewport = gs.viewport;
let content = ensure_layout_cache(&mut gs) let content = ensure_prepared_render(&mut gs)
.map(|cache| cache.content_size) .map(|prepared| prepared.content_size())
.unwrap_or(viewport); .unwrap_or(viewport);
format!( format!(
"{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}", "{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
+177 -8
View File
@@ -11,7 +11,7 @@ pub use deno_core::v8::IsolateHandle;
use crate::module_loader::ObscuraModuleLoader; use crate::module_loader::ObscuraModuleLoader;
#[cfg(feature = "render")] #[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}; use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
@@ -192,17 +192,27 @@ impl ObscuraJsRuntime {
pub fn set_dom(&self, dom: DomTree) { pub fn set_dom(&self, dom: DomTree) {
let mut gs = self.state.borrow_mut(); let mut gs = self.state.borrow_mut();
gs.dom = Some(dom); gs.dom = Some(dom);
// A new document invalidates the layout cache; it is recomputed lazily // A new document owns a fresh retained scene and resource cache.
// on the next geometry read.
#[cfg(feature = "render")] #[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); gs.scroll_offset = (0.0, 0.0);
} }
} }
pub fn set_url(&self, url: &str) { 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 <base> 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 /// Set the document's character encoding (WHATWG canonical name). Backs
@@ -292,7 +302,7 @@ impl ObscuraJsRuntime {
{ {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.viewport = (width as f32, height as f32); state.viewport = (width as f32, height as f32);
state.layout_cache = None; state.prepared_render = None;
} }
let _ = self.runtime.execute_script( let _ = self.runtime.execute_script(
"<set-viewport>", "<set-viewport>",
@@ -319,6 +329,41 @@ impl ObscuraJsRuntime {
clamp_scroll_offset(&mut state, requested) 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<Vec<u8>> {
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.) /// 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. /// have been set. Must be called once per page setup, after all set_* methods.
pub fn run_page_init(&mut self) { pub fn run_page_init(&mut self) {
@@ -1056,7 +1101,13 @@ impl ObscuraJsRuntime {
} }
} }
pub fn take_dom(&self) -> Option<DomTree> { pub fn take_dom(&self) -> Option<DomTree> {
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<R>(&self, f: impl FnOnce(&DomTree) -> R) -> Option<R> { pub fn with_dom<R>(&self, f: impl FnOnce(&DomTree) -> R) -> Option<R> {
@@ -1093,7 +1144,7 @@ impl ObscuraJsRuntime {
} }
#[cfg(feature = "render")] #[cfg(feature = "render")]
{ {
state.layout_cache = None; state.prepared_render = None;
} }
true 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#"<html style="margin:0"><head>
<base href="/assets/">
</head><body style="margin:0">
<div id="frame" style="width:160px">
<img id="hero" src="hero.svg" style="display:block;width:100%;height:auto">
</div>
<div style="height:400px;background:#0000ff"></div>
</body></html>"#,
);
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##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100">
<rect width="400" height="100" fill="#ffff00"/>
</svg>"##
.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")] #[cfg(feature = "render")]
#[test] #[test]
fn rendered_layout_cache_is_invalidated_by_style_mutations() { fn rendered_layout_cache_is_invalidated_by_style_mutations() {