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> { + let resolved_url = resolve_resource_url(src, base_url)?; + if resolved_url.starts_with("data:") { + let mut scratch = RenderResourceCache::with_loader_and_limits( + |_url: &str| None, + 0, + 0, + ); + return Some( + fetch_bytes(&resolved_url, None, &mut scratch) + .and_then(|bytes| image_metadata_from_bytes(&bytes)) + .map(|(width, height)| (resolved_url, width, height)), + ); + } + match self.entries.get(&resolved_url) { + Some(CachedResource::Bytes(bytes)) => Some( + image_metadata_from_bytes(bytes) + .map(|(width, height)| (resolved_url, width, height)), + ), + Some(CachedResource::Missing(at)) + if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => + { + Some(None) + } + _ => None, + } + } + fn get_or_load(&mut self, url: &str) -> Option> { - const MISSING_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2); if let Some(entry) = self.entries.get(url) { match entry { CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)), - CachedResource::Missing(at) if at.elapsed() < MISSING_RETRY_AFTER => return None, + CachedResource::Missing(at) + if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => + { + return None; + } CachedResource::Missing(_) => {} } } @@ -2513,6 +2572,16 @@ fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { .ok() } +fn image_metadata_from_bytes(bytes: &[u8]) -> Option<(f32, f32)> { + let (width, height) = image_dimensions(bytes) + .map(|(width, height)| (width as f32, height as f32)) + .or_else(|| svg_intrinsic(bytes))?; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + Some((width, height)) +} + fn background_image_rect( src: &str, base_url: Option<&str>,