From c5210b1684b03a2abb77afac3a113f2e4f6e50fc Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Fri, 31 Jul 2026 01:32:19 +0000
Subject: [PATCH] Connect image elements to render resources
---
crates/obscura-js/js/bootstrap.js | 279 +++++++++++++++++++++++++----
crates/obscura-js/src/ops.rs | 65 +++++++
crates/obscura-js/src/runtime.rs | 237 ++++++++++++++++++++++++
crates/obscura-render/src/paint.rs | 73 +++++++-
4 files changed, 620 insertions(+), 34 deletions(-)
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index a516784..e97d904 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -3880,6 +3880,249 @@ function _parseWebVttCues(src) {
return cues;
}
+function _imageEncodingError() {
+ return new DOMException("The source image cannot be decoded.", "EncodingError");
+}
+
+// HTMLImageElement is backed by the same retained resource cache used by
+// layout/paint. The render-only native op fetches and sniffs the ordinary
+// `src`; bootstrap owns only the observable request state and event timing.
+// Responsive candidate selection remains explicitly deferred to the renderer.
+class HTMLImageElement extends Element {
+ constructor(nid) {
+ super(nid);
+ this._imageRequest = 0;
+ this._imageQueued = false;
+ this._imageComplete = !this.getAttribute("src");
+ this._imageDecoded = false;
+ this._imageNaturalWidth = 0;
+ this._imageNaturalHeight = 0;
+ this._imageCurrentSrc = "";
+ this._imageDecodeWaiters = [];
+ // Parser images stay lazy until script observes their lifecycle or paint
+ // asks for the same cache entry. Inline handlers are observers too.
+ if (!this._imageComplete
+ && (this.hasAttribute("onload") || this.hasAttribute("onerror"))) {
+ this._queueImageRequest();
+ }
+ }
+
+ get src() {
+ const raw = this.getAttribute("src");
+ if (!raw) return "";
+ try { return new URL(raw, this.baseURI || globalThis.location?.href || "about:blank").href; }
+ catch (_error) { return raw; }
+ }
+ set src(value) { this.setAttribute("src", value); }
+
+ get currentSrc() {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ return this._imageCurrentSrc;
+ }
+ get complete() {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ return this._imageComplete;
+ }
+ get naturalWidth() {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ return this._imageNaturalWidth;
+ }
+ get naturalHeight() {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ return this._imageNaturalHeight;
+ }
+ get onload() { return this._imageOnload || null; }
+ set onload(value) {
+ this._imageOnload = typeof value === "function" ? value : null;
+ if (this._imageOnload) {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ }
+ }
+ get onerror() { return this._imageOnerror || null; }
+ set onerror(value) {
+ this._imageOnerror = typeof value === "function" ? value : null;
+ if (this._imageOnerror) {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ }
+ }
+
+ get width() {
+ const value = Number.parseInt(this.getAttribute("width") || "", 10);
+ return Number.isFinite(value) && value >= 0 ? value : this._imageNaturalWidth;
+ }
+ set width(value) { this.setAttribute("width", Math.max(0, Number(value) || 0)); }
+ get height() {
+ const value = Number.parseInt(this.getAttribute("height") || "", 10);
+ return Number.isFinite(value) && value >= 0 ? value : this._imageNaturalHeight;
+ }
+ set height(value) { this.setAttribute("height", Math.max(0, Number(value) || 0)); }
+
+ get srcset() { return this.getAttribute("srcset") || ""; }
+ set srcset(value) { this.setAttribute("srcset", value); }
+ get sizes() { return this.getAttribute("sizes") || ""; }
+ set sizes(value) { this.setAttribute("sizes", value); }
+ get loading() { return this.getAttribute("loading") || "eager"; }
+ set loading(value) { this.setAttribute("loading", value); }
+ get decoding() { return this.getAttribute("decoding") || "auto"; }
+ set decoding(value) { this.setAttribute("decoding", value); }
+ get fetchPriority() { return this.getAttribute("fetchpriority") || "auto"; }
+ set fetchPriority(value) { this.setAttribute("fetchpriority", value); }
+ get crossOrigin() { return this.getAttribute("crossorigin"); }
+ set crossOrigin(value) {
+ if (value === null) this.removeAttribute("crossorigin");
+ else this.setAttribute("crossorigin", value);
+ }
+
+ setAttribute(name, value) {
+ const normalized = String(name).toLowerCase();
+ super.setAttribute(name, value);
+ if (normalized === "src") this._imageSourceChanged();
+ else if ((normalized === "onload" || normalized === "onerror")
+ && !this._imageComplete) this._queueImageRequest();
+ }
+
+ removeAttribute(name) {
+ const normalized = String(name).toLowerCase();
+ super.removeAttribute(name);
+ if (normalized === "src") this._imageSourceChanged();
+ }
+
+ decode() {
+ this._refreshImageFromCache();
+ if (this._imageComplete) {
+ return this._imageDecoded
+ ? Promise.resolve()
+ : Promise.reject(_imageEncodingError());
+ }
+ this._queueImageRequest();
+ return new Promise((resolve, reject) => {
+ this._imageDecodeWaiters.push({ resolve, reject, request: this._imageRequest });
+ });
+ }
+
+ _imageSourceChanged() {
+ this._rejectImageDecodes();
+ this._imageRequest++;
+ this._imageQueued = false;
+ this._imageNaturalWidth = 0;
+ this._imageNaturalHeight = 0;
+ this._imageDecoded = false;
+ this._imageCurrentSrc = "";
+ this._imageComplete = !this.getAttribute("src");
+ if (!this._imageComplete) this._queueImageRequest();
+ }
+
+ _queueImageRequest() {
+ if (this._imageQueued || this._imageComplete) return;
+ this._imageQueued = true;
+ const request = this._imageRequest;
+ Promise.resolve().then(() => {
+ this._imageQueued = false;
+ if (request === this._imageRequest && !this._imageComplete) {
+ this._runImageRequest(request);
+ }
+ });
+ }
+
+ _runImageRequest(request) {
+ let metadata = null;
+ try {
+ const op = Deno.core.ops.op_image_metadata;
+ if (typeof op === "function") {
+ metadata = JSON.parse(op(this._nid >>> 0, false));
+ } else {
+ // Non-render builds have no authoritative resource cache. Preserve the
+ // old non-blocking compatibility behavior without issuing a duplicate
+ // network fetch: the request succeeds with unknown intrinsic size.
+ metadata = { ok: true, currentSrc: this.src, width: 0, height: 0 };
+ }
+ } catch (_error) {
+ metadata = { ok: false, currentSrc: this.src };
+ }
+ if (request !== this._imageRequest) return;
+ this._applyImageMetadata(metadata, request, true);
+ }
+
+ _refreshImageFromCache() {
+ if (this._imageComplete || !this.getAttribute("src")) return;
+ try {
+ const op = Deno.core.ops.op_image_metadata;
+ if (typeof op !== "function") return;
+ const metadata = JSON.parse(op(this._nid >>> 0, true));
+ if (!metadata || metadata.state === "pending") return;
+ this._applyImageMetadata(metadata, this._imageRequest, false);
+ } catch (_error) {}
+ }
+
+ _applyImageMetadata(metadata, request, dispatchEvent) {
+ if (request !== this._imageRequest) return;
+ this._imageComplete = true;
+ this._imageCurrentSrc = metadata && metadata.currentSrc
+ ? String(metadata.currentSrc)
+ : this.src;
+ const width = Number(metadata && metadata.width);
+ const height = Number(metadata && metadata.height);
+ const loaded = !!(metadata && metadata.ok)
+ && (typeof Deno.core.ops.op_image_metadata !== "function"
+ || (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0));
+ if (loaded) {
+ this._imageDecoded = true;
+ this._imageNaturalWidth = Number.isFinite(width) && width > 0 ? Math.round(width) : 0;
+ this._imageNaturalHeight = Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
+ this._resolveImageDecodes(request);
+ if (dispatchEvent) {
+ try { this.dispatchEvent(new Event("load")); } catch (_error) {}
+ }
+ } else {
+ this._imageDecoded = false;
+ this._imageNaturalWidth = 0;
+ this._imageNaturalHeight = 0;
+ this._rejectImageDecodes(request);
+ if (dispatchEvent) {
+ try { this.dispatchEvent(new Event("error")); } catch (_error) {}
+ }
+ }
+ }
+
+ _resolveImageDecodes(request) {
+ const remaining = [];
+ for (const waiter of this._imageDecodeWaiters) {
+ if (waiter.request === request) waiter.resolve();
+ else remaining.push(waiter);
+ }
+ this._imageDecodeWaiters = remaining;
+ }
+
+ _rejectImageDecodes(request) {
+ const remaining = [];
+ for (const waiter of this._imageDecodeWaiters) {
+ if (request === undefined || waiter.request === request) {
+ waiter.reject(_imageEncodingError());
+ } else {
+ remaining.push(waiter);
+ }
+ }
+ this._imageDecodeWaiters = remaining;
+ }
+
+ addEventListener(type, callback, options) {
+ super.addEventListener(type, callback, options);
+ if ((String(type) === "load" || String(type) === "error") && callback) {
+ this._refreshImageFromCache();
+ this._queueImageRequest();
+ }
+ }
+}
+globalThis.HTMLImageElement = HTMLImageElement;
+_markNative(HTMLImageElement);
+_markNative(HTMLImageElement.prototype.decode);
+
// Media elements need canPlayType for codec detection fingerprinting.
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
class HTMLMediaElement extends Element {
@@ -3959,6 +4202,7 @@ globalThis.VTTCue = VTTCue;
function _elementClassFor(nid) {
const tag = _domParse("tag_name", nid);
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
+ if (tag === "IMG") return HTMLImageElement;
if (tag === "AUDIO") return HTMLAudioElement;
if (tag === "VIDEO") return HTMLVideoElement;
if (tag === "TRACK") return HTMLTrackElement;
@@ -7287,7 +7531,7 @@ globalThis.HTMLDivElement = Element;
globalThis.HTMLSpanElement = Element;
globalThis.HTMLParagraphElement = Element;
globalThis.HTMLAnchorElement = Element;
-globalThis.HTMLImageElement = Element;
+globalThis.HTMLImageElement = HTMLImageElement;
globalThis.HTMLInputElement = Element;
globalThis.HTMLButtonElement = Element;
globalThis.HTMLFormElement = class HTMLFormElement extends Element {
@@ -9472,39 +9716,10 @@ if (typeof Image === 'undefined') {
// `new Image().style` was `undefined` and libraries that touch it on a
// detached image threw (issue #350). Build a real element so `.style`,
// attribute reflection, and event dispatch all come for free.
- const _imgSrcDesc = Object.getOwnPropertyDescriptor(globalThis.HTMLImageElement.prototype, 'src');
globalThis.Image = function Image(width, height) {
const img = document.createElement('img');
- img.onload = null; img.onerror = null;
- img.complete = false; img.naturalWidth = 0; img.naturalHeight = 0;
- img.width = width !== undefined ? (width >>> 0) : 0;
- img.height = height !== undefined ? (height >>> 0) : 0;
- // There is no real image decoder, so emulate a successful decode: assigning
- // `.src` flips `complete` and fires `load` on a microtask-later tick. Lazy
- // loaders and preloaders that create `new Image()`, set `.src`, and wait for
- // `onload` (or addEventListener('load')) would hang forever otherwise.
- // Anti-bot scripts (Booking.com, issue #394) pre-define a non-configurable
- // own `src` on
elements; redefining it throws "Cannot redefine
- // property: src" and kills the constructor. Skip the load emulation then:
- // a page that owns `src` is instrumenting loads itself.
- const ownSrc = Object.getOwnPropertyDescriptor(img, 'src');
- if (!ownSrc || ownSrc.configurable) {
- Object.defineProperty(img, 'src', {
- configurable: true, enumerable: true,
- get() { return _imgSrcDesc.get.call(img); },
- set(v) {
- _imgSrcDesc.set.call(img, v);
- if (!img.getAttribute('src')) return;
- img.complete = false;
- setTimeout(function () {
- img.complete = true;
- img.naturalWidth = img.naturalWidth || img.width || 0;
- img.naturalHeight = img.naturalHeight || img.height || 0;
- try { img.dispatchEvent(new Event('load')); } catch (e) {}
- }, 0);
- },
- });
- }
+ if (width !== undefined) img.width = width >>> 0;
+ if (height !== undefined) img.height = height >>> 0;
return img;
};
globalThis.Image.prototype = globalThis.HTMLImageElement.prototype;
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index 856edbc..2d29d2a 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -2048,6 +2048,7 @@ pub fn build_extension() -> Extension {
// probes with typeof before calling, so the op's absence is a clean fallback.
#[cfg(feature = "render")]
{
+ ops.push(op_image_metadata());
ops.push(op_layout_geometry());
ops.push(op_layout_metrics());
ops.push(op_scroll_offset());
@@ -2102,6 +2103,70 @@ pub(crate) fn ensure_prepared_render(
state.prepared_render.as_ref()
}
+/// Probe one ordinary `
` through the renderer's page-scoped resource
+/// cache. This is deliberately a synchronous render-only op: the current
+/// renderer loader is synchronous too, and sharing it is what guarantees that
+/// HTMLImageElement lifecycle state and the subsequent paint see the same
+/// success/failure and retained bytes.
+#[cfg(feature = "render")]
+#[op2]
+#[string]
+fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
+ let shared = state.borrow::().clone();
+ let mut gs = shared.borrow_mut();
+ let node_id = NodeId::new(nid);
+ let src = gs.dom.as_ref().and_then(|dom| {
+ let node = dom.get_node(node_id)?;
+ let element = node.as_element()?;
+ if element.local.as_ref() != "img" {
+ return None;
+ }
+ node.get_attribute("src").map(str::to_string)
+ });
+ let Some(src) = src.filter(|src| !src.trim().is_empty()) else {
+ return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
+ };
+ let base_url = document_base_url(&gs);
+ let metadata = if cached_only {
+ match gs
+ .render_resources
+ .cached_image_metadata(&src, base_url.as_deref())
+ {
+ None => {
+ return serde_json::json!({ "state": "pending" }).to_string();
+ }
+ Some(metadata) => metadata,
+ }
+ } else {
+ gs.render_resources
+ .image_metadata(&src, base_url.as_deref())
+ };
+ match metadata {
+ Some((current_src, width, height)) => serde_json::json!({
+ "state": "loaded",
+ "ok": true,
+ "currentSrc": current_src,
+ "width": width,
+ "height": height,
+ })
+ .to_string(),
+ None => {
+ let current_src = base_url
+ .as_deref()
+ .and_then(|base| url::Url::parse(base).ok())
+ .and_then(|base| base.join(&src).ok())
+ .map(|url| url.to_string())
+ .unwrap_or(src);
+ serde_json::json!({
+ "state": "error",
+ "ok": false,
+ "currentSrc": current_src,
+ })
+ .to_string()
+ }
+ }
+}
+
#[cfg(feature = "render")]
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
state.scroll_offset = ensure_prepared_render(state)
diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs
index 7aa1233..25e95f7 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -4218,6 +4218,243 @@ mod tests {
assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1);
}
+ #[cfg(feature = "render")]
+ fn parser_image_runtime(
+ html: &str,
+ loader: impl obscura_render::RenderResourceLoader + 'static,
+ ) -> ObscuraJsRuntime {
+ let mut rt = ObscuraJsRuntime::new();
+ rt.set_dom(parse_html(html));
+ rt.set_url("http://example.com/page/index.html");
+ rt.state.borrow_mut().render_resources =
+ obscura_render::RenderResourceCache::with_loader(loader);
+ rt.run_page_init();
+ rt
+ }
+
+ #[cfg(feature = "render")]
+ fn two_by_three_png() -> Vec {
+ use base64::Engine as _;
+ base64::engine::general_purpose::STANDARD
+ .decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6B\
+ AAAAFklEQVR4nGP8z8Dwn4GBgYGJAQrgDAAxOwIE7x6DkQAAAABJRU5ErkJggg=="
+ .replace(char::is_whitespace, ""),
+ )
+ .unwrap()
+ }
+
+ #[cfg(feature = "render")]
+ #[tokio::test(flavor = "current_thread")]
+ async fn parser_image_lifecycle_uses_shared_render_resource() {
+ let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let loader_calls = calls.clone();
+ let png = two_by_three_png();
+ let mut rt = parser_image_runtime(
+ r#"
"#,
+ move |_url: &str| {
+ loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ Some(png.clone())
+ },
+ );
+ rt.execute_script(
+ "observe-parser-image",
+ r#"
+ globalThis.__imageEvents = [];
+ globalThis.__decodeState = "pending";
+ const image = document.getElementById("hero");
+ globalThis.__imageInitial = [
+ image instanceof HTMLImageElement,
+ image.complete,
+ image.naturalWidth,
+ image.naturalHeight
+ ];
+ image.addEventListener("load", () => __imageEvents.push("load"));
+ image.addEventListener("error", () => __imageEvents.push("error"));
+ image.decode().then(
+ () => { __decodeState = "resolved"; },
+ error => { __decodeState = error.name; }
+ );
+ "#,
+ )
+ .unwrap();
+ assert_eq!(
+ rt.evaluate("__imageInitial").unwrap(),
+ serde_json::json!([true, false, 0, 0])
+ );
+
+ rt.run_event_loop_bounded(100).await.unwrap();
+ assert_eq!(
+ rt.evaluate(
+ r#"[
+ image.complete,
+ image.naturalWidth,
+ image.naturalHeight,
+ image.currentSrc,
+ __imageEvents,
+ __decodeState
+ ]"#,
+ )
+ .unwrap(),
+ serde_json::json!([
+ true,
+ 2,
+ 3,
+ "http://example.com/assets/hero.png",
+ ["load"],
+ "resolved"
+ ])
+ );
+ assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+
+ // A subsequent request for the same URL is served by the retained
+ // renderer bytes rather than calling the loader again.
+ rt.execute_script("reload-image", r#"image.src = "../assets/hero.png";"#)
+ .unwrap();
+ rt.run_event_loop_bounded(100).await.unwrap();
+ assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+ }
+
+ #[cfg(feature = "render")]
+ #[tokio::test(flavor = "current_thread")]
+ async fn parser_image_failure_completes_and_rejects_decode() {
+ let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let loader_calls = calls.clone();
+ let mut rt = parser_image_runtime(
+ r#"
"#,
+ move |_url: &str| {
+ loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ None
+ },
+ );
+ rt.execute_script(
+ "observe-broken-image",
+ r#"
+ globalThis.__brokenEvents = [];
+ globalThis.__brokenDecode = "pending";
+ const broken = document.getElementById("broken");
+ broken.onload = () => __brokenEvents.push("load");
+ broken.onerror = () => __brokenEvents.push("error");
+ broken.decode().then(
+ () => { __brokenDecode = "resolved"; },
+ error => { __brokenDecode = error.name; }
+ );
+ "#,
+ )
+ .unwrap();
+ rt.run_event_loop_bounded(100).await.unwrap();
+ assert_eq!(
+ rt.evaluate(
+ "[broken.complete, broken.naturalWidth, broken.naturalHeight, \
+ __brokenEvents, __brokenDecode]"
+ )
+ .unwrap(),
+ serde_json::json!([true, 0, 0, ["error"], "EncodingError"])
+ );
+ assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+ }
+
+ #[cfg(feature = "render")]
+ #[test]
+ fn parser_image_first_getter_observes_prepare_seeded_cache_synchronously() {
+ let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let loader_calls = calls.clone();
+ let png = two_by_three_png();
+ let mut rt = parser_image_runtime(
+ r#"
"#,
+ move |_url: &str| {
+ loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ Some(png.clone())
+ },
+ );
+ {
+ let mut state = rt.state.borrow_mut();
+ assert!(ensure_prepared_render(&mut state).is_some());
+ }
+ assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+
+ // Constructing the JS wrapper happens here, after prepare_dom loaded
+ // the resource. The first complete getter must see that cache hit
+ // immediately; it must not briefly regress to pending/zero.
+ assert_eq!(
+ rt.evaluate(
+ r#"(() => {
+ const cached = document.getElementById("cached");
+ return [
+ cached.complete,
+ cached.naturalWidth,
+ cached.naturalHeight,
+ cached.currentSrc
+ ];
+ })()"#,
+ )
+ .unwrap(),
+ serde_json::json!([
+ true,
+ 2,
+ 3,
+ "http://example.com/page/cached.png"
+ ])
+ );
+ assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+ }
+
+ #[cfg(feature = "render")]
+ #[tokio::test(flavor = "current_thread")]
+ async fn parser_image_source_replacement_cancels_queued_completion() {
+ let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let seen = requests.clone();
+ let first = two_by_three_png();
+ use base64::Engine as _;
+ let second = base64::engine::general_purpose::STANDARD
+ .decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAFCAYAAABirU3b\
+ AAAAFUlEQVR4nGNk+M/wnwEJMDGgATIEAKVaAgg/Jbt7AAAAAElFTkSuQmCC"
+ .replace(char::is_whitespace, ""),
+ )
+ .unwrap();
+ let mut rt = parser_image_runtime(
+ r#"
"#,
+ move |url: &str| {
+ seen.lock().unwrap().push(url.to_string());
+ if url.ends_with("/new.png") {
+ Some(second.clone())
+ } else {
+ Some(first.clone())
+ }
+ },
+ );
+ rt.execute_script(
+ "replace-image-source",
+ r#"
+ globalThis.__swapEvents = [];
+ const swap = document.getElementById("swap");
+ swap.addEventListener("load", () => __swapEvents.push(swap.currentSrc));
+ swap.src = "new.png";
+ "#,
+ )
+ .unwrap();
+ rt.run_event_loop_bounded(100).await.unwrap();
+ assert_eq!(
+ rt.evaluate(
+ "[swap.complete, swap.naturalWidth, swap.naturalHeight, \
+ swap.currentSrc, __swapEvents]"
+ )
+ .unwrap(),
+ serde_json::json!([
+ true,
+ 4,
+ 5,
+ "http://example.com/page/new.png",
+ ["http://example.com/page/new.png"]
+ ])
+ );
+ assert_eq!(
+ *requests.lock().unwrap(),
+ vec!["http://example.com/page/new.png".to_string()]
+ );
+ }
+
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
/// frameworks that probe form field collections work.
#[test]
diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs
index 32d53c9..79c644e 100644
--- a/crates/obscura-render/src/paint.rs
+++ b/crates/obscura-render/src/paint.rs
@@ -22,6 +22,7 @@ use crate::dom::layout_dom_with_web_fonts;
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024;
+const MISSING_RESOURCE_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2);
/// Synchronous byte loader used by [`RenderResourceCache`]. The default
/// implementation uses Obscura's pooled image agent; tests and embedding
@@ -106,12 +107,70 @@ impl RenderResourceCache {
self.retained_bytes
}
+ /// Resolve, fetch, and inspect one image through the exact byte cache used
+ /// by layout and paint. The JS image-element lifecycle calls this narrow
+ /// bridge so `complete`/`naturalWidth` and the eventual screenshot are
+ /// driven by one resource outcome instead of issuing an independent fetch.
+ ///
+ /// This intentionally accepts a plain `src`, not a DOM node: responsive
+ /// `picture`/`srcset` selection remains owned by `prepare_dom`.
+ pub fn image_metadata(
+ &mut self,
+ src: &str,
+ base_url: Option<&str>,
+ ) -> Option<(String, f32, f32)> {
+ let resolved_url = resolve_resource_url(src, base_url)?;
+ let bytes = fetch_bytes(&resolved_url, None, self)?;
+ let (width, height) = image_metadata_from_bytes(&bytes)?;
+ Some((resolved_url, width, height))
+ }
+
+ /// Inspect an image only when its renderer-cache outcome is already known.
+ /// `None` means no live cache entry (the caller may queue a load);
+ /// `Some(None)` means a retained load/decode failure; `Some(Some(...))`
+ /// means success. `data:` sources have no network entry and are cheap to
+ /// inspect directly.
+ pub fn cached_image_metadata(
+ &self,
+ src: &str,
+ base_url: Option<&str>,
+ ) -> Option