diff --git a/Cargo.lock b/Cargo.lock index 1a3705f8b..4865a825a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,6 +1311,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.12" @@ -1719,6 +1725,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "flume" version = "0.11.1" @@ -2485,6 +2497,12 @@ dependencies = [ "tiff", ] +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "include_dir" version = "0.7.4" @@ -2771,6 +2789,18 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3923,6 +3953,7 @@ dependencies = [ "op-editor-core", "op-i18n", "op-util", + "resvg", "serde", "serde_json", ] @@ -4499,6 +4530,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.11" @@ -4615,6 +4652,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -5073,6 +5119,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", +] + [[package]] name = "rfd" version = "0.14.1" @@ -5096,6 +5157,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -5110,6 +5180,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rquickjs" version = "0.12.1" @@ -5379,7 +5458,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5629,6 +5708,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -5802,6 +5890,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "string_cache" @@ -5840,6 +5931,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.117" @@ -6027,7 +6128,22 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", ] [[package]] @@ -6041,6 +6157,17 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -6491,6 +6618,25 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "data-url", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", +] + [[package]] name = "utf-8" version = "0.7.6" diff --git a/crates/op-editor-ui/Cargo.toml b/crates/op-editor-ui/Cargo.toml index 0db242f68..33d2025c9 100644 --- a/crates/op-editor-ui/Cargo.toml +++ b/crates/op-editor-ui/Cargo.toml @@ -43,6 +43,17 @@ base64 = "0.22" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +# Rasterize SVG image sources into PNG at the byte-cache seam — skia +# decodes PNG/JPEG/GIF/WebP, not SVG, so every captured-page `` +# fallback and remote `.svg` painted as a placeholder. NATIVE ONLY: +# measured +0.9 MiB gzip on the wasm bundle (over the 6 MiB ceiling), +# and the browser host doesn't need it — its CanvasKit bridge falls +# back to the browser's own SVG decoder at decode time. Minimal feature +# set on purpose: no `text` (needs fontdb + system fonts) and no +# `raster-images` (not worth carrying the `image` crate). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +resvg = { version = "0.48", default-features = false } + [dev-dependencies] # Enable jian-scene's find-visit instrumentation so the canvas-viewport # paint tests can assert scene-traversal visit counts. diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_image.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_image.rs index c0726234a..a4d937b00 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_image.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_image.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex, OnceLock}; mod decode_registry; mod picture_glyph; +mod svg_raster; #[cfg(test)] mod test_support; @@ -181,8 +182,11 @@ pub fn store_remote_image_bytes(id: u64, bytes: Vec) { mark_remote_image_failed(id); return; } + // A remote `.svg` decodes in neither skia nor CanvasKit — rasterize at + // the seam so the cache only ever holds bitmap codecs. + let bytes = svg_raster::ensure_raster_bytes(Arc::from(bytes.into_boxed_slice())); if let Ok(mut cache) = data_url_cache().lock() { - cache.insert(id, Arc::from(bytes.into_boxed_slice())); + cache.insert(id, bytes); } if let Ok(mut reg) = remote_images().lock() { reg.requested.remove(&id); @@ -259,7 +263,9 @@ pub(crate) fn image_source_bytes(src: &str, image_src_id: u64) -> Option Option> { let meta = &after_scheme[..comma]; let payload = &after_scheme[comma + 1..]; if !meta.contains(";base64") { - return None; + // The only textual `data:` payload worth carrying is SVG markup — + // CSS commonly embeds icons as `data:image/svg+xml,%3Csvg...` + // percent-encoded rather than base64. Anything else stays undecoded + // as before. + if !meta.contains("image/svg") { + return None; + } + let decoded = percent_decode(payload)?; + return Some(Arc::from(decoded.into_boxed_slice())); } use base64::engine::general_purpose::STANDARD as B64; @@ -289,6 +303,26 @@ fn decode_data_url_bytes(src: &str) -> Option> { Some(Arc::from(decoded.into_boxed_slice())) } +/// Decode `%XX` escapes; every other byte passes through verbatim (a data +/// URI does not use `+` for space). +fn percent_decode(payload: &str) -> Option> { + let bytes = payload.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + while at < bytes.len() { + if bytes[at] == b'%' { + let hex = bytes.get(at + 1..at + 3)?; + let hex = std::str::from_utf8(hex).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + at += 3; + } else { + out.push(bytes[at]); + at += 1; + } + } + Some(out) +} + /// Paint a raster image inside `world_rect`. The source bytes and decoded /// backend image are both cached, so repeated canvas paints do not re-decode /// data URLs while importing or panning a Figma-heavy document. diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_image/svg_raster.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_image/svg_raster.rs new file mode 100644 index 000000000..27b023b3a --- /dev/null +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_image/svg_raster.rs @@ -0,0 +1,147 @@ +//! SVG → PNG rasterization at the image byte-cache seam. +//! +//! Skia (native) and CanvasKit (web) decode PNG / JPEG / GIF / WebP — not +//! SVG — so an SVG that reaches the painter as bytes (a captured page's +//! `data:image/svg+xml` fallback, a remote `.svg` the host fetched) used to +//! paint as the dashed placeholder forever. Every byte payload entering the +//! shared cache passes through [`ensure_raster_bytes`]: SVG sources are +//! rasterized once with resvg and cached as PNG, everything else passes +//! through untouched, and both hosts then paint an ordinary bitmap. +//! +//! Deliberately minimal resvg: no `text` support (it needs fontdb and system +//! font access the wasm host cannot have), so an `` with `` +//! renders its shapes only. The capture side already vectorizes flat-colour +//! icon art into native path nodes; this is the fallback for everything that +//! path cannot express (strokes, gradients, masks, mixed art). + +use std::sync::Arc; + +/// Longest raster edge produced for an SVG, in pixels. Matches the capture +/// side's own image ceiling; anything larger is downscaled to fit. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SVG_RASTER_EDGE: f32 = 2048.0; +/// Oversampling for small art so a zoomed-in icon stays crisp instead of +/// blurring at the first 2× zoom. +#[cfg(not(target_arch = "wasm32"))] +const SVG_RASTER_SCALE: f32 = 2.0; + +/// Rasterize `bytes` when they are an SVG document; return them unchanged +/// otherwise (including on any parse or render failure — the caller then +/// paints the usual undecodable-image placeholder, exactly as before). +/// +/// On wasm32 this is a pass-through: resvg measured +0.9 MiB gzip against +/// the web bundle's 6 MiB ceiling, and the browser host does not need it — +/// its CanvasKit bridge (`op_ck_image_cache.js`) falls back to the +/// browser's own SVG decoder when `MakeImageFromEncoded` rejects the bytes. +pub(crate) fn ensure_raster_bytes(bytes: Arc<[u8]>) -> Arc<[u8]> { + #[cfg(target_arch = "wasm32")] + { + bytes + } + #[cfg(not(target_arch = "wasm32"))] + { + if !sniffs_as_svg(&bytes) { + return bytes; + } + match rasterize(&bytes) { + Some(png) => Arc::from(png.into_boxed_slice()), + None => bytes, + } + } +} + +/// Loose sniff: the payload reads as XML-ish text whose first 4 KiB contain +/// an ` bool { + let head = &bytes[..bytes.len().min(4096)]; + let Ok(text) = std::str::from_utf8(head) else { + return false; + }; + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + trimmed.starts_with('<') && trimmed.contains(" Option> { + let options = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_data(bytes, &options).ok()?; + // `usvg::Size` guarantees positive finite dimensions by construction; + // the guards keep that assumption local instead of trusting it forever. + let size = tree.size(); + if size.width() <= 0.0 || size.height() <= 0.0 { + return None; + } + let longest = size.width().max(size.height()); + let scale = SVG_RASTER_SCALE.min(MAX_SVG_RASTER_EDGE / longest); + if scale <= 0.0 || !scale.is_finite() { + return None; + } + let width = (size.width() * scale).round().max(1.0) as u32; + let height = (size.height() * scale).round().max(1.0) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + pixmap.encode_png().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const RED_RECT_SVG: &str = r##""##; + + fn arc(bytes: &[u8]) -> Arc<[u8]> { + Arc::from(bytes.to_vec().into_boxed_slice()) + } + + #[test] + fn an_svg_document_becomes_png_bytes() { + let out = ensure_raster_bytes(arc(RED_RECT_SVG.as_bytes())); + assert!(out.starts_with(b"\x89PNG\r\n\x1a\n"), "rasterized to PNG"); + } + + #[test] + fn a_stroked_svg_also_rasterizes() { + let svg = r##""##; + let out = ensure_raster_bytes(arc(svg.as_bytes())); + assert!(out.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn png_bytes_pass_through_untouched() { + let png = b"\x89PNG\r\n\x1a\nrest".to_vec(); + let out = ensure_raster_bytes(arc(&png)); + assert_eq!(&out[..], &png[..]); + } + + #[test] + fn malformed_svg_text_passes_through() { + let bytes = b"{RED_RECT_SVG}"); + let out = ensure_raster_bytes(arc(svg.as_bytes())); + assert!(out.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + /// An enormous authored size is clamped to the raster ceiling instead of + /// allocating a multi-gigabyte pixmap. + #[test] + fn an_oversized_svg_is_clamped_to_the_edge_ceiling() { + let svg = r##""##; + let out = ensure_raster_bytes(arc(svg.as_bytes())); + assert!(out.starts_with(b"\x89PNG\r\n\x1a\n")); + // PNG width lives in the IHDR chunk at offset 16..20. + let width = u32::from_be_bytes([out[16], out[17], out[18], out[19]]); + assert!(width <= 2048, "width {width} must be clamped"); + } +} diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_image/tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_image/tests.rs index 194b27b0e..76b5491ab 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_image/tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_image/tests.rs @@ -247,6 +247,32 @@ fn data_url_cache_reuses_decoded_bytes() { assert_eq!(data_url_cache_len_for_tests(), 1); } +/// A captured page's `data:image/svg+xml` fallback rasterizes to PNG at the +/// cache seam — skia and CanvasKit decode neither, so caching the raw SVG +/// bytes would paint the placeholder forever. +#[test] +fn an_svg_data_url_is_rasterized_into_png_bytes() { + let _guard = lock_statics(); + let svg = r##""##; + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine as _; + let src = format!("data:image/svg+xml;base64,{}", B64.encode(svg)); + + let bytes = image_source_bytes(&src, 11).expect("decode"); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "cached as PNG"); +} + +/// CSS embeds icons as percent-encoded (non-base64) svg data URIs; those +/// must decode and rasterize the same way. +#[test] +fn a_percent_encoded_svg_data_url_also_rasterizes() { + let _guard = lock_statics(); + let src = "data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%224%22%20height=%224%22%3E%3Crect%20width=%224%22%20height=%224%22%20fill=%22%23ff00ff%22/%3E%3C/svg%3E"; + + let bytes = image_source_bytes(src, 12).expect("decode"); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "cached as PNG"); +} + #[test] fn undecoded_image_is_queued_without_drawing_encoded_bytes() { let _guard = lock_statics(); diff --git a/crates/op-host-desktop/src/remote_image_host.rs b/crates/op-host-desktop/src/remote_image_host.rs index 44060d8de..6a454f662 100644 --- a/crates/op-host-desktop/src/remote_image_host.rs +++ b/crates/op-host-desktop/src/remote_image_host.rs @@ -169,6 +169,9 @@ fn fetch_remote_image_blocking(url: &str) -> Result, AssetFetchError> { /// Magic-byte sniff for the codecs skia decodes (PNG / JPEG / GIF / /// WebP / BMP) — used when the server omits an `image/*` content type. +/// SVG markup is also accepted: it has no magic bytes, and the painter's +/// byte-cache seam rasterizes it to PNG before skia ever sees it, so +/// rejecting it here is what kept every remote `.svg` a placeholder. fn looks_like_image(bytes: &[u8]) -> bool { bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.starts_with(&[0xFF, 0xD8, 0xFF]) @@ -176,6 +179,16 @@ fn looks_like_image(bytes: &[u8]) -> bool { || bytes.starts_with(b"GIF89a") || (bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP") || bytes.starts_with(b"BM") + || looks_like_svg(bytes) +} + +fn looks_like_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(4096)]; + let Ok(text) = std::str::from_utf8(head) else { + return false; + }; + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + trimmed.starts_with('<') && trimmed.contains(" { if (image && image.delete) image.delete(); }; +// SVG has no magic bytes — sniff the markup (first 4 KiB decode as text and +// contain an ` { + try { + const head = new TextDecoder().decode( + bytes.subarray(0, Math.min(bytes.byteLength, 4096)), + ); + const trimmed = head.replace(/^/, '').trimStart(); + return trimmed.startsWith('<') && trimmed.includes(' { + if (svgFailures.size >= SVG_FAILURE_CAP) { + svgFailures.delete(svgFailures.values().next().value); + } + svgFailures.add(key); + }; + + const installRasterizedSvg = (key, img) => { + const sourceW = img.naturalWidth || img.width; + const sourceH = img.naturalHeight || img.height; + if (!(sourceW > 0) || !(sourceH > 0)) return false; + const scale = Math.min( + SVG_RASTER_SCALE, + SVG_RASTER_MAX_EDGE / Math.max(sourceW, sourceH), + ); + if (!(scale > 0)) return false; + const width = Math.max(1, Math.round(sourceW * scale)); + const height = Math.max(1, Math.round(sourceH * scale)); + const surface = document.createElement('canvas'); + surface.width = width; + surface.height = height; + const context = surface.getContext('2d'); + if (!context) return false; + context.drawImage(img, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height); + if (!CK.MakeImage) return false; + const image = CK.MakeImage( + { + width, + height, + colorType: CK.ColorType.RGBA_8888, + alphaType: CK.AlphaType.Unpremul, + colorSpace: CK.ColorSpace.SRGB, + }, + pixels.data, + 4 * width, + ); + if (!image) return false; + const bytes = decodedRasterBytes(image); + if (!(bytes > 0)) { + deleteImage(image); + return false; + } + fullImageCache.set(key, { + image, + bytes, + coversEdgePx: Number.MAX_SAFE_INTEGER, + }); + fullImageCacheBytes += bytes; + evictFullImages(); + return fullImageCache.has(key); + }; + + // The browser is the SVG decoder CanvasKit lacks: load the markup into an + // `` (async), draw it onto a 2d canvas, and install the pixels as an + // ordinary CanvasKit image. resvg does this on native; carrying it into + // the wasm bundle would bust the 6 MiB ceiling for a codec the platform + // already ships. + const startSvgRaster = (key, encoded) => { + if (svgPending.has(key)) return; + svgPending.add(key); + let url = null; + const settle = (installed) => { + if (url) URL.revokeObjectURL(url); + svgPending.delete(key); + if (!installed) rememberSvgFailure(key); + }; + try { + const blob = new Blob([copyBytes(encoded)], { type: 'image/svg+xml' }); + url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + try { + settle(installRasterizedSvg(key, img)); + } catch (_error) { + settle(false); + } + }; + img.onerror = () => settle(false); + img.src = url; + } catch (_error) { + settle(false); + } + }; const evictFullImages = () => { while ( @@ -145,7 +255,19 @@ export function createWebImageCaches(CK) { let image = null; try { image = CK.MakeImageFromEncoded(copyBytes(encoded)); - if (!image) return false; + if (!image) { + // CanvasKit has no SVG codec — hand the markup to the browser's own + // decoder. Reporting `true` while the async raster is in flight + // keeps the id off the Rust side's permanent-failure cache; paint + // re-requests each frame until the raster lands (or the failure is + // remembered here and this returns false for good). + if (svgFailures.has(key)) return false; + if (sniffsAsSvg(encoded)) { + startSvgRaster(key, encoded); + return true; + } + return false; + } const bytes = decodedRasterBytes(image); if (!(bytes > 0)) { deleteImage(image);