Connect image elements to render resources
This commit is contained in:
1 parent
476191d9c8
commit
c5210b1684
4 files changed
+620
-34
No files matched your search
Vendored
+247
-32
@@ -3880,6 +3880,249 @@ function _parseWebVttCues(src) {
|
|||||||
return cues;
|
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.
|
// Media elements need canPlayType for codec detection fingerprinting.
|
||||||
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
|
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
|
||||||
class HTMLMediaElement extends Element {
|
class HTMLMediaElement extends Element {
|
||||||
@@ -3959,6 +4202,7 @@ globalThis.VTTCue = VTTCue;
|
|||||||
function _elementClassFor(nid) {
|
function _elementClassFor(nid) {
|
||||||
const tag = _domParse("tag_name", nid);
|
const tag = _domParse("tag_name", nid);
|
||||||
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
|
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
|
||||||
|
if (tag === "IMG") return HTMLImageElement;
|
||||||
if (tag === "AUDIO") return HTMLAudioElement;
|
if (tag === "AUDIO") return HTMLAudioElement;
|
||||||
if (tag === "VIDEO") return HTMLVideoElement;
|
if (tag === "VIDEO") return HTMLVideoElement;
|
||||||
if (tag === "TRACK") return HTMLTrackElement;
|
if (tag === "TRACK") return HTMLTrackElement;
|
||||||
@@ -7287,7 +7531,7 @@ globalThis.HTMLDivElement = Element;
|
|||||||
globalThis.HTMLSpanElement = Element;
|
globalThis.HTMLSpanElement = Element;
|
||||||
globalThis.HTMLParagraphElement = Element;
|
globalThis.HTMLParagraphElement = Element;
|
||||||
globalThis.HTMLAnchorElement = Element;
|
globalThis.HTMLAnchorElement = Element;
|
||||||
globalThis.HTMLImageElement = Element;
|
globalThis.HTMLImageElement = HTMLImageElement;
|
||||||
globalThis.HTMLInputElement = Element;
|
globalThis.HTMLInputElement = Element;
|
||||||
globalThis.HTMLButtonElement = Element;
|
globalThis.HTMLButtonElement = Element;
|
||||||
globalThis.HTMLFormElement = class HTMLFormElement extends 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
|
// `new Image().style` was `undefined` and libraries that touch it on a
|
||||||
// detached image threw (issue #350). Build a real element so `.style`,
|
// detached image threw (issue #350). Build a real element so `.style`,
|
||||||
// attribute reflection, and event dispatch all come for free.
|
// attribute reflection, and event dispatch all come for free.
|
||||||
const _imgSrcDesc = Object.getOwnPropertyDescriptor(globalThis.HTMLImageElement.prototype, 'src');
|
|
||||||
globalThis.Image = function Image(width, height) {
|
globalThis.Image = function Image(width, height) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.onload = null; img.onerror = null;
|
if (width !== undefined) img.width = width >>> 0;
|
||||||
img.complete = false; img.naturalWidth = 0; img.naturalHeight = 0;
|
if (height !== undefined) img.height = height >>> 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 <img> 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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return img;
|
return img;
|
||||||
};
|
};
|
||||||
globalThis.Image.prototype = globalThis.HTMLImageElement.prototype;
|
globalThis.Image.prototype = globalThis.HTMLImageElement.prototype;
|
||||||
|
|||||||
@@ -2048,6 +2048,7 @@ pub fn build_extension() -> Extension {
|
|||||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
|
ops.push(op_image_metadata());
|
||||||
ops.push(op_layout_geometry());
|
ops.push(op_layout_geometry());
|
||||||
ops.push(op_layout_metrics());
|
ops.push(op_layout_metrics());
|
||||||
ops.push(op_scroll_offset());
|
ops.push(op_scroll_offset());
|
||||||
@@ -2102,6 +2103,70 @@ pub(crate) fn ensure_prepared_render(
|
|||||||
state.prepared_render.as_ref()
|
state.prepared_render.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe one ordinary `<img src>` 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::<SharedState>().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")]
|
#[cfg(feature = "render")]
|
||||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
||||||
state.scroll_offset = ensure_prepared_render(state)
|
state.scroll_offset = ensure_prepared_render(state)
|
||||||
|
|||||||
@@ -4218,6 +4218,243 @@ mod tests {
|
|||||||
assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1);
|
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<u8> {
|
||||||
|
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#"<img id="hero" src="../assets/hero.png">"#,
|
||||||
|
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#"<img id="broken" src="missing.png">"#,
|
||||||
|
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#"<img id="cached" src="cached.png">"#,
|
||||||
|
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#"<img id="swap" src="old.png">"#,
|
||||||
|
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
|
/// Regression for #105: `HTMLFormElement` must expose `.elements` so
|
||||||
/// frameworks that probe form field collections work.
|
/// frameworks that probe form field collections work.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::dom::layout_dom_with_web_fonts;
|
|||||||
|
|
||||||
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
|
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
|
||||||
const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
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
|
/// Synchronous byte loader used by [`RenderResourceCache`]. The default
|
||||||
/// implementation uses Obscura's pooled image agent; tests and embedding
|
/// implementation uses Obscura's pooled image agent; tests and embedding
|
||||||
@@ -106,12 +107,70 @@ impl RenderResourceCache {
|
|||||||
self.retained_bytes
|
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<Option<(String, f32, f32)>> {
|
||||||
|
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<Arc<[u8]>> {
|
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
|
||||||
const MISSING_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2);
|
|
||||||
if let Some(entry) = self.entries.get(url) {
|
if let Some(entry) = self.entries.get(url) {
|
||||||
match entry {
|
match entry {
|
||||||
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
|
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(_) => {}
|
CachedResource::Missing(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2513,6 +2572,16 @@ fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
|||||||
.ok()
|
.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(
|
fn background_image_rect(
|
||||||
src: &str,
|
src: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
|
|||||||
Reference in new issue
Block a user