Prepare fast document-space captures
This commit is contained in:
1 parent
ceb6fa52ca
commit
51a3eb1a81
6 files changed
+1105
-26
No files matched your search
@@ -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")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3_000);
|
||||
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)
|
||||
});
|
||||
// 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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(®ion_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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user