Share prepared layout with CSSOM
This commit is contained in:
1 parent
6d4ce11aa1
commit
5057715358
3 files changed
+241
-75
No files matched your search
@@ -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()
|
||||
|
||||
@@ -64,14 +64,6 @@ pub struct JsNetworkEvent {
|
||||
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 dom: Option<DomTree>,
|
||||
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<JsNetworkEvent>,
|
||||
/// 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<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")]
|
||||
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<String> {
|
||||
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::<SharedState>().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\":{}}}",
|
||||
|
||||
@@ -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 <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
|
||||
@@ -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(
|
||||
"<set-viewport>",
|
||||
@@ -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<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.)
|
||||
/// 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<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> {
|
||||
@@ -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#"<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")]
|
||||
#[test]
|
||||
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
|
||||
|
||||
Reference in new issue
Block a user