Prepare fast document-space captures

This commit is contained in:
SGavrl committed 2026-08-03 16:33:47 +00:00
1 parent ceb6fa52ca
commit 51a3eb1a81
6 files changed
+1103 -24

No files matched your search

+524 -11
View File
@@ -402,6 +402,106 @@ fn rebase_css_urls(css: &str, base: &url::Url) -> String {
out
}
/// Extract network-backed `url(...)` assets while respecting CSS comments and
/// strings. Linked sheets have already been rebased before materialization;
/// inline declarations are resolved against the document base here.
fn css_resource_urls(css: &str, base: &url::Url) -> Vec<String> {
let mut urls = Vec::new();
let mut index = 0usize;
while index < css.len() {
let rest = &css[index..];
if rest.starts_with("/*") {
if let Some(end) = rest[2..].find("*/") {
index += end + 4;
} else {
break;
}
continue;
}
let Some(first) = rest.chars().next() else { break };
if first == '"' || first == '\'' {
let quote = first;
let mut escaped = false;
let mut length = quote.len_utf8();
for ch in rest[quote.len_utf8()..].chars() {
length += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
break;
}
}
index += length;
continue;
}
if !rest.get(..4).is_some_and(|prefix| prefix.eq_ignore_ascii_case("url(")) {
index += first.len_utf8();
continue;
}
let mut quote = None;
let mut escaped = false;
let mut end = None;
for (offset, ch) in rest[4..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
match quote {
Some(open) if ch == open => quote = None,
Some(_) => {}
None if ch == '"' || ch == '\'' => quote = Some(ch),
None if ch == ')' => {
end = Some(4 + offset);
break;
}
None => {}
}
}
let Some(end) = end else { break };
let raw = rest[4..end].trim();
let value = if raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\'')))
{
&raw[1..raw.len() - 1]
} else {
raw
};
if !value.is_empty()
&& !value.starts_with('#')
&& !value.starts_with("data:")
&& !value.contains("var(")
{
if let Ok(mut url) = base.join(value) {
url.set_fragment(None);
if matches!(url.scheme(), "http" | "https") {
urls.push(url.to_string());
}
}
}
index += end + 1;
}
urls
}
fn render_resource_type(url: &url::Url) -> ResourceType {
let path = url.path().to_ascii_lowercase();
if [".woff", ".woff2", ".ttf", ".otf", ".eot"]
.iter()
.any(|extension| path.ends_with(extension))
{
ResourceType::Font
} else {
ResourceType::Image
}
}
/// Pull leading `@import` rules out of a stylesheet. Returns each import target
/// URL (skipping print-only and `prefers-color-scheme: dark` conditional
/// imports, which must not apply in our light desktop context) plus the CSS
@@ -955,6 +1055,11 @@ impl Page {
}
async fn execute_scripts(&mut self) {
self.execute_scripts_with_module_budget(None).await;
}
async fn execute_scripts_with_module_budget(&mut self, module_budget_override: Option<u64>) {
let scripts_started = std::time::Instant::now();
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
// Soft deadline on the entire script-execution phase. Heavy SPAs
// (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial
@@ -1280,13 +1385,19 @@ impl Page {
})
})
.unwrap_or(0);
let short_ms: u64 = std::env::var("OBSCURA_MODULE_BUDGET_MS")
let short_ms: u64 = module_budget_override.unwrap_or_else(|| {
std::env::var("OBSCURA_MODULE_BUDGET_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3_000);
.unwrap_or(3_000)
});
// A rendered body has hundreds of descendants; an unmounted Vite/Next
// shell is <root> plus maybe a spinner.
if body_nodes > 50 { short_ms } else { script_deadline_ms }
if module_budget_override.is_some() || body_nodes > 50 {
short_ms
} else {
script_deadline_ms
}
};
enum ScheduledScript {
@@ -1294,9 +1405,22 @@ impl Page {
Module {
prepared: obscura_js::runtime::PreparedModule,
url: Option<String>,
deadline: tokio::time::Instant,
started: std::time::Instant,
},
}
let remaining_budget_ms = |deadline: tokio::time::Instant| -> Option<u64> {
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
if remaining.is_zero() {
return None;
}
let millis = remaining.as_millis().saturating_add(u128::from(
remaining.subsec_nanos() % 1_000_000 != 0,
));
Some(millis.min(u128::from(u64::MAX)) as u64)
};
let execute_classic = |
page: &mut Self,
script: &ScriptInfo,
@@ -1375,6 +1499,19 @@ impl Page {
}
}
ScriptKind::Module => {
// This is one load+evaluation budget, not one allowance per
// phase. Cap it by the page-wide script deadline as well.
let module_started = std::time::Instant::now();
let module_deadline = std::cmp::min(
script_deadline,
tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(module_budget_ms),
);
let Some(prepare_budget_ms) = remaining_budget_ms(module_deadline) else {
tracing::warn!("ES module budget exhausted before graph preparation");
continue;
};
let prepare_started = std::time::Instant::now();
let (prepared, module_url) = if let Some(src) = &script.src {
let full_url = if src.starts_with("http://")
|| src.starts_with("https://")
@@ -1390,9 +1527,17 @@ impl Page {
};
tracing::info!("Preparing ES module graph: {}", full_url);
let result = match &mut self.js {
Some(js) => js.prepare_module(&full_url, module_budget_ms).await,
Some(js) => js.prepare_module(&full_url, prepare_budget_ms).await,
None => continue,
};
tracing::debug!(
phase = "module-graph",
module = %full_url,
elapsed_ms = prepare_started.elapsed().as_millis(),
budget_ms = prepare_budget_ms,
success = result.is_ok(),
"ES module phase complete",
);
match result {
Ok(prepared) => (prepared, Some(full_url)),
Err(error) => {
@@ -1403,10 +1548,18 @@ impl Page {
} else {
let result = match &mut self.js {
Some(js) => js.prepare_inline_module(
&script.inline, &script.base_url, module_budget_ms,
&script.inline, &script.base_url, prepare_budget_ms,
).await,
None => continue,
};
tracing::debug!(
phase = "module-graph",
module = "<inline>",
elapsed_ms = prepare_started.elapsed().as_millis(),
budget_ms = prepare_budget_ms,
success = result.is_ok(),
"ES module phase complete",
);
match result {
Ok(prepared) => (prepared, None),
Err(error) => {
@@ -1418,15 +1571,42 @@ impl Page {
let scheduled = ScheduledScript::Module {
prepared,
url: module_url,
deadline: module_deadline,
started: module_started,
};
if script.is_async {
let ScheduledScript::Module { prepared, url } = scheduled else {
let ScheduledScript::Module {
prepared,
url,
deadline,
started,
} = scheduled else {
unreachable!();
};
let Some(evaluation_budget_ms) = remaining_budget_ms(deadline) else {
tracing::warn!(
module = url.as_deref().unwrap_or("<inline>"),
elapsed_ms = started.elapsed().as_millis(),
"ES module exhausted its shared budget before evaluation",
);
continue;
};
let evaluation_started = std::time::Instant::now();
let result = match &mut self.js {
Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await,
Some(js) => js
.evaluate_prepared_module(prepared, evaluation_budget_ms)
.await,
None => continue,
};
tracing::debug!(
phase = "module-evaluation",
module = url.as_deref().unwrap_or("<inline>"),
elapsed_ms = evaluation_started.elapsed().as_millis(),
total_elapsed_ms = started.elapsed().as_millis(),
budget_ms = evaluation_budget_ms,
success = result.is_ok(),
"ES module phase complete",
);
if let Err(error) = result {
tracing::warn!("ES module evaluation error: {}", error);
} else if let Some(url) = url {
@@ -1454,11 +1634,36 @@ impl Page {
let fetched_script = fetched.remove(&index);
execute_classic(self, script, fetched_script);
}
ScheduledScript::Module { prepared, url } => {
ScheduledScript::Module {
prepared,
url,
deadline,
started,
} => {
let Some(evaluation_budget_ms) = remaining_budget_ms(deadline) else {
tracing::warn!(
module = url.as_deref().unwrap_or("<inline>"),
elapsed_ms = started.elapsed().as_millis(),
"ES module exhausted its shared budget before post-parse evaluation",
);
continue;
};
let evaluation_started = std::time::Instant::now();
let result = match &mut self.js {
Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await,
Some(js) => js
.evaluate_prepared_module(prepared, evaluation_budget_ms)
.await,
None => continue,
};
tracing::debug!(
phase = "module-evaluation",
module = url.as_deref().unwrap_or("<inline>"),
elapsed_ms = evaluation_started.elapsed().as_millis(),
total_elapsed_ms = started.elapsed().as_millis(),
budget_ms = evaluation_budget_ms,
success = result.is_ok(),
"ES module phase complete",
);
if let Err(error) = result {
tracing::warn!("ES module evaluation error: {}", error);
} else if let Some(url) = url {
@@ -1548,6 +1753,12 @@ impl Page {
js.disarm_watchdog(token);
}
}
tracing::debug!(
phase = "script-execution-total",
elapsed_ms = scripts_started.elapsed().as_millis(),
budget_ms = script_deadline_ms,
"script execution phase complete",
);
}
pub async fn navigate(&mut self, url_str: &str) -> Result<(), PageError> {
@@ -1986,6 +2197,157 @@ impl Page {
self.dom.as_ref().map(f)
}
/// Concurrently seed the synchronous renderer cache through the owning
/// page transport. This removes serial image/font HTTP from the first
/// screenshot while retaining cookies, proxy policy, interception, CORS,
/// response limits, and connection pooling.
#[cfg(feature = "render")]
pub async fn prepare_screenshot_resources(&mut self, max_ms: u64) -> usize {
let started = std::time::Instant::now();
if max_ms == 0 || self.js.is_none() {
return 0;
}
let Some(document_url) = self.url.clone() else {
return 0;
};
let base_url = self.resolve_base_url().unwrap_or_else(|| document_url.clone());
let mut candidates = std::collections::BTreeMap::new();
if let Some(js) = &self.js {
for raw in js.pending_render_image_urls() {
if let Ok(mut url) = url::Url::parse(&raw) {
url.set_fragment(None);
candidates.insert(url.to_string(), ResourceType::Image);
}
}
let css_sources = js.with_dom(|dom| {
let mut sources = Vec::new();
for id in dom.descendants(dom.document()) {
let Some(node) = dom.get_node(id) else { continue };
if node
.as_element()
.is_some_and(|element| element.local.as_ref() == "style")
{
sources.push(dom.text_content(id));
}
if let Some(style) = node.get_attribute("style") {
sources.push(style.to_string());
}
if node
.as_element()
.is_some_and(|element| element.local.as_ref() == "use")
{
if let Some(href) = node
.get_attribute("href")
.or_else(|| node.get_attribute("xlink:href"))
{
sources.push(format!("url({href})"));
}
}
}
sources
}).unwrap_or_default();
for css in css_sources {
for raw in css_resource_urls(&css, &base_url) {
if let Ok(url) = url::Url::parse(&raw) {
candidates.insert(raw, render_resource_type(&url));
}
}
}
candidates.retain(|url, _| !js.render_resource_is_known(url));
}
candidates.retain(|url, _| {
subresource_allowed(Some(&document_url), url) && !self.should_block_url(url)
});
if candidates.len() > 128 {
candidates = candidates.into_iter().take(128).collect();
}
if candidates.is_empty() {
return 0;
}
let requested: Vec<(String, ResourceType)> = candidates.into_iter().collect();
let client = self.http_client.clone();
#[cfg(feature = "stealth")]
let stealth_client = self.stealth_client.clone();
let callbacks = self.callbacks.clone();
let initiator = document_url.clone();
use futures::StreamExt as _;
let requests = futures::stream::iter(requested.clone().into_iter().map(|(raw, kind)| {
let client = client.clone();
#[cfg(feature = "stealth")]
let stealth_client = stealth_client.clone();
let callbacks = callbacks.clone();
let initiator = initiator.clone();
async move {
let parsed = url::Url::parse(&raw).expect("validated render resource URL");
let request = ResourceRequest::subresource(kind, &initiator);
#[cfg(feature = "stealth")]
let result = if let Some(stealth_client) = stealth_client {
stealth_client
.fetch_resource_with_callbacks(&parsed, request, Some(&callbacks))
.await
} else {
client
.fetch_resource_with_callbacks(&parsed, request, Some(&callbacks))
.await
};
#[cfg(not(feature = "stealth"))]
let result = client
.fetch_resource_with_callbacks(&parsed, request, Some(&callbacks))
.await;
(raw, kind, result)
}
})).buffer_unordered(16);
futures::pin_mut!(requests);
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(max_ms);
let mut outcomes = std::collections::HashMap::new();
loop {
match tokio::time::timeout_at(deadline, requests.next()).await {
Ok(Some((raw, kind, result))) => {
outcomes.insert(raw, (kind, result));
}
Ok(None) | Err(_) => break,
}
}
drop(requests);
let mut loaded = 0usize;
for (raw, kind) in requested {
let bytes = match outcomes.remove(&raw) {
Some((_, Ok(response))) => {
self.record_network_event_with_body(
response.url.as_str(),
"GET",
match kind { ResourceType::Font => "Font", _ => "Image" },
response.status,
&response.headers,
&response.body,
true,
);
if (200..300).contains(&response.status) {
loaded += 1;
Some(response.body)
} else {
None
}
}
_ => None,
};
if let Some(js) = &mut self.js {
js.seed_render_resource(raw, bytes);
}
}
tracing::debug!(
loaded,
elapsed_ms = started.elapsed().as_millis(),
"prepared screenshot resources through page transport"
);
loaded
}
/// Rasterize the current DOM to PNG bytes at `viewport` (CSS pixels), when
/// the render feature is compiled in. None if the page has no DOM or the
/// viewport is zero-sized.
@@ -2542,13 +2904,33 @@ fn url_matches_cdp_pattern(pattern: &str, url: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
linked_stylesheet_requests, materialize_linked_stylesheet_script, parse_import_url,
navigation_referrer, rebase_css_urls, split_css_imports,
css_resource_urls, linked_stylesheet_requests, materialize_linked_stylesheet_script,
parse_import_url, navigation_referrer, rebase_css_urls, split_css_imports,
script_response_is_executable, truncate_on_char_boundary, url_matches_cdp_pattern,
};
use base64::Engine as _;
use obscura_dom::parse_html;
#[test]
fn css_resource_discovery_ignores_strings_comments_data_and_fragments() {
let base = url::Url::parse("https://example.test/css/app/main.css").unwrap();
let css = r#"
/* url(ignored.png) */
.copy::before { content: "url(also-ignored.png)"; }
.hero { background: url('../img/hero.png'); }
.icon { mask: URL("https://cdn.test/icon.svg#shape"); }
.inline { background: url(data:image/svg+xml,<svg/>); }
.local { mask: url(#local); }
"#;
assert_eq!(
css_resource_urls(css, &base),
vec![
"https://example.test/css/img/hero.png".to_string(),
"https://cdn.test/icon.svg".to_string(),
]
);
}
fn spawn_stylesheet_graph_server(
expected_requests: usize,
) -> (String, std::sync::mpsc::Receiver<String>) {
@@ -3021,6 +3403,73 @@ mod tests {
page
}
#[tokio::test(flavor = "current_thread")]
async fn module_graph_and_evaluation_share_one_absolute_budget() {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (request_tx, request_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let path = String::from_utf8_lossy(&request[..length])
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/")
.to_string();
request_tx.send(path).unwrap();
// Spend part of the module's allowance loading its graph. The
// synchronous top-level work then fits in a freshly reset budget,
// but cannot fit in the one absolute load+evaluation budget.
std::thread::sleep(std::time::Duration::from_millis(100));
let body = "export const delayed = true;";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
});
let base = format!("http://{address}");
let mut page = import_map_test_page(
"shared-module-budget",
&base,
r#"<html><head><script type="module">
import "./delayed.js";
globalThis.__shared_deadline_started = true;
const until = Date.now() + 300;
while (Date.now() < until) {}
globalThis.__shared_deadline_completed = true;
</script></head><body></body></html>"#,
);
page.execute_scripts_with_module_budget(Some(350)).await;
assert_eq!(
request_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap(),
"/app/delayed.js",
);
let state = page
.js
.as_mut()
.unwrap()
.evaluate(
"[globalThis.__shared_deadline_started === true, \
globalThis.__shared_deadline_completed === true]",
)
.unwrap();
assert_eq!(
state,
serde_json::json!([true, false]),
"evaluation must be terminated at the remaining shared deadline",
);
}
#[tokio::test(flavor = "current_thread")]
async fn parser_import_map_before_first_module_controls_resolution() {
let (base, requests) = spawn_parser_import_map_server(1);
@@ -3168,6 +3617,70 @@ mod tests {
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js");
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn page_transport_prefetches_once_and_capture_reuses_the_bytes() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = listener.local_addr().unwrap();
let (seen_tx, seen_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0u8; 2048];
let read = stream.read(&mut request).unwrap_or(0);
let first = String::from_utf8_lossy(&request[..read])
.lines().next().unwrap_or_default().to_string();
seen_tx.send(first).unwrap();
let body = br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(response.as_bytes()).unwrap();
stream.write_all(body).unwrap();
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(_) => break,
}
}
});
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"render-prefetch".to_string(), None, false, None, None, true,
));
let mut page = super::Page::new("render-prefetch".to_string(), context);
page.set_viewport((100.0, 80.0));
let page_url = format!("http://{address}/page");
let asset_url = format!("http://{address}/asset.svg");
let dom = parse_html(&format!(
r#"<html><body><img src="{asset_url}" style="width:20px;height:10px"></body></html>"#
));
let mut runtime = obscura_js::runtime::ObscuraJsRuntime::new();
runtime.set_dom(dom);
runtime.set_url(&page_url);
runtime.set_viewport(100.0, 80.0);
runtime.run_page_init();
page.js = Some(runtime);
page.url = Some(url::Url::parse(&page_url).unwrap());
assert_eq!(page.prepare_screenshot_resources(1_000).await, 1);
page.screenshot(page.viewport).expect("prefetched capture");
assert!(seen_rx
.recv_timeout(std::time::Duration::from_secs(1))
.unwrap()
.starts_with("GET /asset.svg "));
assert!(
seen_rx.recv_timeout(std::time::Duration::from_millis(200)).is_err(),
"capture must not open a second synchronous renderer request"
);
}
#[cfg(feature = "render")]
#[test]
fn page_screenshot_uses_the_live_window_scroll_offset() {
+5
View File
@@ -781,6 +781,11 @@ async fn run_fetch(
if let Some(ref path) = screenshot {
#[cfg(feature = "render")]
{
let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(3_000);
let _ = page.prepare_screenshot_resources(resource_deadline_ms).await;
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
// capture below-the-fold content in one shot).
+4 -1
View File
@@ -12,4 +12,7 @@ pub use v8_flags::set_v8_flags;
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
// render feature (which enables obscura-render/paint) is compiled in.
#[cfg(feature = "render")]
pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
pub use obscura_render::{
screenshot_png, screenshot_png_scrolled, CaptureError, CaptureRegion,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
+235 -5
View File
@@ -64,6 +64,19 @@ pub struct PreparedModule {
description: String,
}
fn remaining_deadline_ms(deadline: tokio::time::Instant) -> Option<u64> {
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
if remaining.is_zero() {
return None;
}
// Round up so a positive sub-millisecond remainder still gets one bounded
// event-loop turn. The watchdog supplies the hard wall-clock boundary.
let millis = remaining.as_millis().saturating_add(
u128::from(remaining.subsec_nanos() % 1_000_000 != 0),
);
Some(millis.min(u128::from(u64::MAX)) as u64)
}
/// Handle to an armed V8 execution watchdog (see [`ObscuraJsRuntime::arm_watchdog`]).
/// Holds the cancel channel and the watchdog thread; pass it back to
/// `disarm_watchdog` to stop the watchdog and learn whether it fired.
@@ -406,6 +419,91 @@ impl ObscuraJsRuntime {
)
}
/// Capture a document-space rectangle without changing the live viewport,
/// root scroll, element scroll offsets, or retained layout. The current
/// resolved scroll snapshot supplies fixed/sticky and nested-scroll state.
#[cfg(feature = "render")]
pub fn screenshot_prepared_region(
&self,
region: obscura_render::CaptureRegion,
) -> Result<Vec<u8>, obscura_render::CaptureError> {
let mut state = self.state.borrow_mut();
ensure_resolved_scroll(&mut state).ok_or(obscura_render::CaptureError::PaintFailed)?;
let ObscuraState {
dom,
prepared_render,
render_resources,
resolved_scroll,
..
} = &mut *state;
let (_, scroll) = resolved_scroll
.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?;
obscura_render::screenshot_prepared_region_with_scroll(
dom.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
prepared_render
.as_mut()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
render_resources,
scroll,
region,
)
}
/// Return the exact responsive candidates selected for live `<img>`
/// elements without loading them. The browser layer can then fetch them
/// concurrently through the page-owned transport before synchronous layout
/// or paint observes the cache.
#[cfg(feature = "render")]
pub fn pending_render_image_urls(&self) -> Vec<String> {
let state = self.state.borrow();
let base_url = document_base_url(&state);
let Some(dom) = state.dom.as_ref() else {
return Vec::new();
};
let mut urls = Vec::new();
for id in dom.descendants(dom.document()) {
let Some(node) = dom.get_node(id) else { continue };
if node
.as_element()
.is_none_or(|element| element.local.as_ref() != "img")
{
continue;
}
let Some((url, _, known, _)) = state.render_resources
.cached_image_element_metadata(dom, id, state.viewport, base_url.as_deref())
else {
continue;
};
if !known && !url.starts_with("data:") {
urls.push(url);
}
}
urls.sort();
urls.dedup();
urls
}
/// Insert one page-transport resource outcome into the retained renderer
/// cache. A new successful byte body can change intrinsic geometry, so any
/// previously prepared layout is invalidated.
#[cfg(feature = "render")]
pub fn seed_render_resource(&mut self, url: String, bytes: Option<Vec<u8>>) {
let mut state = self.state.borrow_mut();
match bytes {
Some(bytes) => state.render_resources.seed(url, bytes),
None => state.render_resources.seed_missing(url),
}
state.prepared_render = None;
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
pub fn render_resource_is_known(&self, url: &str) -> bool {
self.state.borrow().render_resources.has_live_outcome(url)
}
/// 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) {
@@ -786,8 +884,13 @@ impl ObscuraJsRuntime {
self.object_store.clear();
}
pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> {
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(budget_ms);
let prepared = self.prepare_module(url, budget_ms).await?;
self.evaluate_prepared_module(prepared, budget_ms).await
let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| {
format!("Module {} exhausted its {}ms load+evaluation budget", url, budget_ms)
})?;
self.evaluate_prepared_module(prepared, remaining_ms).await
}
pub async fn prepare_module(&mut self, url: &str, budget_ms: u64) -> Result<PreparedModule, String> {
@@ -869,10 +972,15 @@ impl ObscuraJsRuntime {
}
pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> {
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(budget_ms);
let prepared = self
.prepare_inline_module(code, base_url, budget_ms)
.await?;
self.evaluate_prepared_module(prepared, budget_ms).await
let remaining_ms = remaining_deadline_ms(deadline).ok_or_else(|| {
format!("Inline module exhausted its {}ms load+evaluation budget", budget_ms)
})?;
self.evaluate_prepared_module(prepared, remaining_ms).await
}
pub async fn prepare_inline_module(
@@ -923,8 +1031,27 @@ impl ObscuraJsRuntime {
prepared: PreparedModule,
budget_ms: u64,
) -> Result<(), String> {
self.drive_module_eval(prepared.module_id, budget_ms, &prepared.description)
.await
let PreparedModule {
module_id,
description,
} = prepared;
// Tokio timeouts cannot run while synchronous top-level module work
// pins the runtime thread in V8. Pair the async timeout with a hard V8
// watchdog so this budget is a real wall-clock ceiling for both forms
// of evaluation.
let watchdog = self.arm_watchdog(std::time::Duration::from_millis(budget_ms));
let result = self
.drive_module_eval(module_id, budget_ms, &description)
.await;
let watchdog_fired = self.disarm_watchdog(watchdog);
if watchdog_fired {
Err(format!(
"{} evaluation timed out after {}ms",
description, budget_ms
))
} else {
result
}
}
fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> {
@@ -4803,6 +4930,109 @@ mod tests {
);
}
#[cfg(feature = "render")]
#[test]
fn document_region_capture_preserves_live_runtime_state_and_resource_cache() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0;height:260px">
<div style="height:120px;background:red"></div>
<img src="http://example.test/marker.svg"
style="display:block;width:20px;height:20px">
<div style="height:120px;background:blue"></div>
<div style="position:fixed;left:0;top:0;width:10px;height:10px;background:lime"></div>
</body></html>"#,
);
let loads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let loader_loads = loads.clone();
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_url("http://example.test/page");
rt.set_viewport(80.0, 60.0);
rt.state.borrow_mut().render_resources =
obscura_render::RenderResourceCache::with_loader(move |url: &str| {
assert_eq!(url, "http://example.test/marker.svg");
loader_loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">
<rect width="20" height="20" fill="#ffff00"/>
</svg>"##
.to_vec(),
)
});
rt.run_page_init();
assert_eq!(
rt.evaluate("(function(){ window.scrollTo(0, 50); return window.scrollY; })()")
.expect("live scroll")
.as_f64(),
Some(50.0)
);
let live_before = rt
.screenshot_prepared((80.0, 60.0), Some("http://example.test/page"))
.expect("live screenshot");
let (prepared_address, viewport, scroll_offset, scroll_generation, resolved_root, full_height) = {
let state = rt.state.borrow();
let prepared = state.prepared_render.as_ref().expect("prepared render");
(
prepared as *const obscura_render::PreparedRender as usize,
state.viewport,
state.scroll_offset,
state.scroll_generation,
state
.resolved_scroll
.as_ref()
.expect("resolved scroll")
.1
.root_offset(),
prepared.content_size().1,
)
};
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
let region_png = rt
.screenshot_prepared_region(obscura_render::CaptureRegion::new(
0.0, 115.0, 80.0, 40.0, 1.5,
))
.expect("offscreen scaled region");
let full_png = rt
.screenshot_prepared_region(obscura_render::CaptureRegion::new(
0.0, 0.0, 80.0, full_height, 1.0,
))
.expect("full-content region");
let png_size = |bytes: &[u8]| {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
(
u32::from_be_bytes(bytes[16..20].try_into().expect("PNG width")),
u32::from_be_bytes(bytes[20..24].try_into().expect("PNG height")),
)
};
assert_eq!(png_size(&region_png), (120, 60));
assert_eq!(png_size(&full_png), (80, full_height.ceil() as u32));
let live_after = rt
.screenshot_prepared((80.0, 60.0), Some("http://example.test/page"))
.expect("unchanged live screenshot");
assert_eq!(live_after, live_before);
let state = rt.state.borrow();
let prepared = state.prepared_render.as_ref().expect("retained render");
assert_eq!(
prepared as *const obscura_render::PreparedRender as usize,
prepared_address
);
assert_eq!(state.viewport, viewport);
assert_eq!(state.scroll_offset, scroll_offset);
assert_eq!(state.scroll_generation, scroll_generation);
assert_eq!(
state
.resolved_scroll
.as_ref()
.expect("retained resolved scroll")
.1
.root_offset(),
resolved_root
);
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "render")]
#[test]
fn script_registered_url_font_reaches_render_resource_collection() {
@@ -8348,7 +8578,7 @@ mod tests {
.await
.unwrap_err();
assert!(
error.contains("Inline module evaluation timed out after 20ms"),
error.contains("Inline module evaluation timed out after"),
"expected evaluation timeout, got: {}",
error
);
+5 -4
View File
@@ -93,11 +93,12 @@ mod image_capability_tests {
mod paint;
#[cfg(feature = "paint")]
pub use paint::{
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_with_scroll, prepare_dom,
prepare_dom_with_dynamic_fonts, screenshot_png, screenshot_png_scrolled,
screenshot_prepared, screenshot_prepared_with_scroll, DynamicFontFace,
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png,
screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll,
screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace,
ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader,
ResolvedScrollState, SelectedImage,
ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
};
// Real inline text layout (cosmic-text) lives behind the paint feature; the
+330 -3
View File
@@ -354,6 +354,91 @@ pub struct ResolvedScrollState {
inherited_clips: Vec<Option<crate::dom::OverflowClip>>,
}
/// A rectangle in immutable document CSS-pixel coordinates.
///
/// `scale` controls output pixels per CSS pixel without changing the prepared
/// layout viewport. This is the same separation Chromium makes between the
/// page-space clip and the screenshot surface scale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CaptureRegion {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub scale: f32,
}
impl CaptureRegion {
pub fn new(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Self {
Self {
x,
y,
width,
height,
scale,
}
}
}
/// Hard surface limits for one capture. The byte cap bounds both the native
/// CSS-pixel raster and the scaled output before either allocation occurs.
pub const MAX_CAPTURE_DIMENSION: u32 = 32_768;
pub const MAX_CAPTURE_PIXELS: u64 = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptureError {
InvalidRegion,
AllocationLimitExceeded,
PaintFailed,
EncodeFailed,
}
fn checked_capture_dimensions(
region: CaptureRegion,
) -> Result<(u32, u32, u32, u32), CaptureError> {
if !region.x.is_finite()
|| !region.y.is_finite()
|| !region.width.is_finite()
|| !region.height.is_finite()
|| !region.scale.is_finite()
|| region.width <= 0.0
|| region.height <= 0.0
|| region.scale <= 0.0
{
return Err(CaptureError::InvalidRegion);
}
let native_width = region.width.ceil();
let native_height = region.height.ceil();
let output_width = (region.width * region.scale).ceil();
let output_height = (region.height * region.scale).ceil();
if !native_width.is_finite()
|| !native_height.is_finite()
|| !output_width.is_finite()
|| !output_height.is_finite()
|| native_width > MAX_CAPTURE_DIMENSION as f32
|| native_height > MAX_CAPTURE_DIMENSION as f32
|| output_width > MAX_CAPTURE_DIMENSION as f32
|| output_height > MAX_CAPTURE_DIMENSION as f32
{
return Err(CaptureError::AllocationLimitExceeded);
}
let dimensions = (
native_width as u32,
native_height as u32,
output_width as u32,
output_height as u32,
);
for (width, height) in [
(dimensions.0, dimensions.1),
(dimensions.2, dimensions.3),
] {
if u64::from(width).saturating_mul(u64::from(height)) > MAX_CAPTURE_PIXELS {
return Err(CaptureError::AllocationLimitExceeded);
}
}
Ok(dimensions)
}
impl ResolvedScrollState {
pub fn root_offset(&self) -> (f32, f32) {
self.root_offset
@@ -1419,6 +1504,7 @@ pub fn paint_prepared(
None,
None,
None,
None,
(0.0, 0.0),
)
}
@@ -1453,10 +1539,80 @@ pub fn paint_prepared_with_scroll(
None,
None,
None,
None,
(0.0, 0.0),
)
}
/// Paint an arbitrary document-space rectangle from one retained layout and
/// resolved scroll snapshot. The live root viewport remains the containing
/// block for fixed and sticky descendants, while ordinary document content is
/// translated directly to the requested page-space origin.
pub fn paint_prepared_region_with_scroll(
tree: &DomTree,
prepared: &mut PreparedRender,
resources: &mut RenderResourceCache,
scroll: &ResolvedScrollState,
region: CaptureRegion,
) -> Result<Pixmap, CaptureError> {
let (native_width, native_height, output_width, output_height) =
checked_capture_dimensions(region)?;
let mut pixmap = Pixmap::new(native_width, native_height)
.ok_or(CaptureError::AllocationLimitExceeded)?;
pixmap.fill(Color::WHITE);
// Resolved node movement already contains `-root_scroll` for ordinary
// document content and zero root movement for fixed content. Adding the
// live root offset back before subtracting the capture origin therefore
// maps ordinary nodes to page space while leaving fixed/sticky nodes at
// their live viewport's document-space position.
let root = scroll.root_offset();
let surface_offset = (root.0 - region.x, root.1 - region.y);
let pixmap = paint_laid_dom_scrolled(
tree,
prepared.viewport,
prepared.base_url.as_deref(),
root,
Some(scroll),
pixmap,
resources,
&prepared.selected_images,
&prepared.svg_fonts,
prepared.content_size,
&prepared.viewport_fixed,
&prepared.sticky,
&mut prepared.layout,
None,
None,
None,
None,
None,
Some((region.width, region.height)),
surface_offset,
)
.ok_or(CaptureError::PaintFailed)?;
if output_width == native_width && output_height == native_height {
return Ok(pixmap);
}
// The retained painter currently rasterizes glyphs and decoded images in
// CSS-pixel space. Keep scale out of layout and scroll state, then use one
// premultiplied-alpha high-quality surface transform. Vector primitives
// still take the direct raster path whenever scale is 1.
let source = image::RgbaImage::from_raw(native_width, native_height, pixmap.data().to_vec())
.ok_or(CaptureError::PaintFailed)?;
let scaled = image::imageops::resize(
&source,
output_width,
output_height,
image::imageops::FilterType::Lanczos3,
);
let size = tiny_skia::IntSize::from_wh(output_width, output_height)
.ok_or(CaptureError::AllocationLimitExceeded)?;
Pixmap::from_vec(scaled.into_raw(), size).ok_or(CaptureError::PaintFailed)
}
/// The used `z-index` for an element which participates in stacking order.
///
/// A non-auto z-index applies not only to positioned boxes, but also to
@@ -1574,6 +1730,7 @@ fn paint_laid_dom_scrolled(
suppress_stacking_for: Option<obscura_dom::tree::NodeId>,
suppress_transform_for: Option<obscura_dom::tree::NodeId>,
clip_scope_root: Option<obscura_dom::tree::NodeId>,
surface_extent: Option<(f32, f32)>,
surface_offset: (f32, f32),
) -> Option<Pixmap> {
let scroll_state = match resolved_scroll {
@@ -1583,6 +1740,7 @@ fn paint_laid_dom_scrolled(
viewport_fixed,
resolved,
clip_scope_root,
surface_extent,
surface_offset,
),
None => ScrollPaintState::new(
@@ -1593,6 +1751,7 @@ fn paint_laid_dom_scrolled(
viewport_fixed,
sticky,
clip_scope_root,
surface_extent,
surface_offset,
),
};
@@ -1778,6 +1937,7 @@ fn paint_laid_dom_scrolled(
Some(nid),
suppress_transform_for,
clip_scope_root,
surface_extent,
surface_offset,
)?;
continue;
@@ -1859,6 +2019,7 @@ fn paint_laid_dom_scrolled(
suppress_stacking_for,
Some(nid),
clip_scope_root,
surface_extent,
surface_offset,
)?;
continue;
@@ -1928,6 +2089,7 @@ fn paint_laid_dom_scrolled(
suppress_stacking_for,
Some(nid),
Some(nid),
surface_extent,
(
surface_offset.0 + layer_delta.0,
surface_offset.1 + layer_delta.1,
@@ -1994,6 +2156,7 @@ fn paint_laid_dom_scrolled(
suppress_stacking_for,
suppress_transform_for,
clip_scope_root,
surface_extent,
surface_offset,
)?;
let group_paint = tiny_skia::PixmapPaint {
@@ -2827,6 +2990,7 @@ struct ScrollPaintState<'a> {
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
resolved: Option<&'a ResolvedScrollState>,
clip_scope_root: Option<obscura_dom::tree::NodeId>,
surface_extent: Option<(f32, f32)>,
surface_offset: (f32, f32),
active: bool,
}
@@ -2840,6 +3004,7 @@ impl<'a> ScrollPaintState<'a> {
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
sticky_layout: &crate::StickyLayout,
clip_scope_root: Option<obscura_dom::tree::NodeId>,
surface_extent: Option<(f32, f32)>,
surface_offset: (f32, f32),
) -> Self {
let scroll_x = if requested.0.is_finite() {
@@ -2870,6 +3035,7 @@ impl<'a> ScrollPaintState<'a> {
sticky_clips: std::collections::HashMap::new(),
resolved: None,
clip_scope_root,
surface_extent,
surface_offset,
active,
};
@@ -2886,6 +3052,7 @@ impl<'a> ScrollPaintState<'a> {
sticky_clips,
resolved: None,
clip_scope_root,
surface_extent,
surface_offset,
active,
}
@@ -2897,6 +3064,7 @@ impl<'a> ScrollPaintState<'a> {
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
resolved: &'a ResolvedScrollState,
clip_scope_root: Option<obscura_dom::tree::NodeId>,
surface_extent: Option<(f32, f32)>,
surface_offset: (f32, f32),
) -> Self {
Self {
@@ -2908,6 +3076,7 @@ impl<'a> ScrollPaintState<'a> {
sticky_clips: std::collections::HashMap::new(),
resolved: Some(resolved),
clip_scope_root,
surface_extent,
surface_offset,
active: true,
}
@@ -2942,7 +3111,7 @@ impl<'a> ScrollPaintState<'a> {
id: obscura_dom::tree::NodeId,
) -> Option<crate::Rect> {
self.overflow_clip_for(laid, id)
.map(|clip| clip.viewport_rect(self.viewport))
.map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport)))
}
fn overflow_clip_for(
@@ -3015,7 +3184,8 @@ impl<'a> ScrollPaintState<'a> {
let inherited = self.overflow_clip_for(laid, id);
let style = laid.styles.get(&id)?;
if !style.overflow_hidden || style.overflow_propagated_to_viewport {
return inherited.map(|clip| clip.viewport_rect(self.viewport));
return inherited
.map(|clip| clip.viewport_rect(self.surface_extent.unwrap_or(self.viewport)));
}
let rect = laid.rects.get(&id)?;
let (ox, oy) = self.translation_for(laid, id);
@@ -3025,7 +3195,7 @@ impl<'a> ScrollPaintState<'a> {
Some(clip) => clip.intersect(own),
None => own,
}
.viewport_rect(self.viewport),
.viewport_rect(self.surface_extent.unwrap_or(self.viewport)),
)
}
@@ -3767,6 +3937,19 @@ pub fn screenshot_prepared_with_scroll(
.ok()
}
/// PNG convenience wrapper for [`paint_prepared_region_with_scroll`].
pub fn screenshot_prepared_region_with_scroll(
tree: &DomTree,
prepared: &mut PreparedRender,
resources: &mut RenderResourceCache,
scroll: &ResolvedScrollState,
region: CaptureRegion,
) -> Result<Vec<u8>, CaptureError> {
paint_prepared_region_with_scroll(tree, prepared, resources, scroll, region)?
.encode_png()
.map_err(|_| CaptureError::EncodeFailed)
}
/// A representative visible color for `background-clip: text` text whose own
/// color is transparent, used on the word-split paint path (the cosmic-text IFC
/// path samples the gradient per glyph in `inline`). Returns the gradient's mid
@@ -10092,4 +10275,148 @@ mod tests {
"newly visible nested content did not move into the scrollport"
);
}
#[test]
fn document_region_capture_reuses_layout_scroll_and_resources() {
let tree = parse_html(
r#"<html style="margin:0"><body style="margin:0;height:300px;background:white">
<div style="height:80px;background:red"></div>
<div id="sticky" style="position:sticky;z-index:1;top:0;margin-left:20px;
width:10px;height:10px;background:yellow"></div>
<div style="height:210px;background:green"></div>
<div id="offscreen" style="position:absolute;left:40px;top:150px;
width:10px;height:10px;background:magenta"></div>
<img src="fixture.svg" style="position:absolute;left:60px;top:200px;
width:10px;height:10px">
<div id="fixed" style="position:fixed;left:0;top:0;width:10px;
height:10px;background:blue"></div>
</body></html>"#,
);
let loads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let loader_loads = loads.clone();
let mut resources = RenderResourceCache::with_loader(move |url: &str| {
assert_eq!(url, "https://example.test/fixture.svg");
loader_loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
<rect width="10" height="10" fill="#00ffff"/>
</svg>"##
.to_vec(),
)
});
let mut prepared = prepare_dom(
&tree,
(80.0, 60.0),
Some("https://example.test/page"),
&mut resources,
)
.expect("prepared render");
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
let scroll = prepared.resolve_scroll_state(&tree, (0.0, 100.0), &HashMap::new());
let fixed = tree.get_element_by_id("fixed").expect("fixed");
let before_fixed = prepared
.viewport_rect_with_scroll(fixed, &scroll)
.expect("fixed geometry");
let offscreen = paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
CaptureRegion::new(0.0, 100.0, 80.0, 80.0, 1.0),
)
.expect("offscreen region");
assert_eq!((offscreen.width(), offscreen.height()), (80, 80));
let fixed_pixel = offscreen.pixel(5, 5).expect("fixed pixel");
assert!(fixed_pixel.blue() > 240 && fixed_pixel.red() < 20);
let sticky_pixel = offscreen.pixel(25, 5).expect("sticky pixel");
assert!(sticky_pixel.red() > 240 && sticky_pixel.green() > 240);
let target_pixel = offscreen.pixel(45, 55).expect("offscreen target");
assert!(target_pixel.red() > 240 && target_pixel.blue() > 240);
let full_height = prepared.content_size().1;
let full = paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
CaptureRegion::new(0.0, 0.0, 80.0, full_height, 1.0),
)
.expect("full-content region");
assert_eq!(full.height(), full_height.ceil() as u32);
let fixed_at_live_viewport = full.pixel(5, 105).expect("full fixed pixel");
assert!(fixed_at_live_viewport.blue() > 240 && fixed_at_live_viewport.red() < 20);
let fixed_not_duplicated = full.pixel(5, 5).expect("document top pixel");
assert!(fixed_not_duplicated.red() > 240 && fixed_not_duplicated.blue() < 20);
let scaled = paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
CaptureRegion::new(40.0, 150.0, 10.0, 10.0, 2.0),
)
.expect("scaled region");
assert_eq!((scaled.width(), scaled.height()), (20, 20));
let scaled_center = scaled.pixel(10, 10).expect("scaled center");
assert!(scaled_center.red() > 240 && scaled_center.blue() > 240);
assert_eq!(scroll.root_offset(), (0.0, 100.0));
assert_eq!(
prepared
.viewport_rect_with_scroll(fixed, &scroll)
.expect("unchanged fixed geometry"),
before_fixed
);
assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() {
let tree = parse_html("<div style='width:10px;height:10px;background:red'></div>");
let mut resources = RenderResourceCache::with_loader(|_url: &str| None);
let mut prepared = prepare_dom(&tree, (20.0, 20.0), None, &mut resources)
.expect("prepared render");
let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new());
for invalid in [
CaptureRegion::new(0.0, 0.0, 0.0, 10.0, 1.0),
CaptureRegion::new(f32::NAN, 0.0, 10.0, 10.0, 1.0),
CaptureRegion::new(0.0, 0.0, 10.0, 10.0, f32::INFINITY),
] {
assert_eq!(
paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
invalid,
)
.unwrap_err(),
CaptureError::InvalidRegion
);
}
assert_eq!(
paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
CaptureRegion::new(0.0, 0.0, MAX_CAPTURE_DIMENSION as f32, 10.0, 2.0),
)
.unwrap_err(),
CaptureError::AllocationLimitExceeded
);
assert_eq!(
paint_prepared_region_with_scroll(
&tree,
&mut prepared,
&mut resources,
&scroll,
CaptureRegion::new(0.0, 0.0, 9000.0, 9000.0, 1.0),
)
.unwrap_err(),
CaptureError::AllocationLimitExceeded
);
}
}