feat(renderer): rasterize svg image sources at the byte-cache seam

Skia and CanvasKit decode PNG/JPEG/GIF/WebP but not SVG, so every
captured page's inline-svg fallback and remote .svg painted as the
dashed placeholder forever.

- Native: resvg (minimal features, no text/raster-images) rasterizes
  SVG bytes to PNG where they enter the shared byte cache, so both the
  data-URI decode and the remote-fetch store paths only ever cache
  bitmap codecs. Target-gated off wasm32: measured +0.9 MiB gzip
  against the web bundle's 6 MiB ceiling.
- Web: the CanvasKit bridge falls back to the browser's own SVG
  decoder (async Image + 2d canvas -> CK.MakeImage). Pending decodes
  report success so the id is not negative-cached; the repaint pump
  keeps frames coming until the raster lands.
- The remote-image fetcher's magic-byte sniff now accepts SVG markup,
  and percent-encoded (non-base64) svg data URIs decode too.
This commit is contained in:
Kayshen-X 2026-08-05 21:11:13 +08:00
parent 72792315e9
commit a9c1401e1a
7 changed files with 505 additions and 6 deletions

150
Cargo.lock generated
View file

@ -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"

View file

@ -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 `<svg>`
# 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.

View file

@ -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<u8>) {
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<Arc<[u8
note_remote_image_miss(id, src);
return None;
}
let decoded = decode_data_url_bytes(src)?;
// An SVG data URI decodes in neither skia nor CanvasKit — rasterize at
// the seam so the cache only ever holds bitmap codecs.
let decoded = svg_raster::ensure_raster_bytes(decode_data_url_bytes(src)?);
if let Ok(mut cache) = data_url_cache().lock() {
cache.insert(id, decoded.clone());
}
@ -272,7 +278,15 @@ fn decode_data_url_bytes(src: &str) -> Option<Arc<[u8]>> {
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<Arc<[u8]>> {
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<Vec<u8>> {
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.

View file

@ -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 `<svg>` with `<text>`
//! 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 `<svg` tag. A false positive only costs a failed parse, which passes
/// the bytes through unchanged.
#[cfg(not(target_arch = "wasm32"))]
fn sniffs_as_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("<svg")
}
#[cfg(not(target_arch = "wasm32"))]
fn rasterize(bytes: &[u8]) -> Option<Vec<u8>> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="#ff0000"/></svg>"##;
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##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M4 4L20 20" stroke="#123456" stroke-width="2" fill="none"/></svg>"##;
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"<svg definitely not well formed".to_vec();
let out = ensure_raster_bytes(arc(&bytes));
assert_eq!(&out[..], &bytes[..]);
}
#[test]
fn a_leading_xml_prolog_and_bom_still_sniff() {
let svg = format!("\u{feff}<?xml version=\"1.0\"?>{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##"<svg xmlns="http://www.w3.org/2000/svg" width="100000" height="50"><rect width="100000" height="50" fill="#00ff00"/></svg>"##;
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");
}
}

View file

@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#0000ff"/></svg>"##;
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();

View file

@ -169,6 +169,9 @@ fn fetch_remote_image_blocking(url: &str) -> Result<Vec<u8>, 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("<svg")
}
#[cfg(test)]

View file

@ -69,12 +69,122 @@ const deleteImage = (image) => {
if (image && image.delete) image.delete();
};
// SVG has no magic bytes — sniff the markup (first 4 KiB decode as text and
// contain an `<svg` tag). Mirrors the native seam's sniff in
// `op-editor-ui/.../svg_raster.rs`.
const sniffsAsSvg = (bytes) => {
try {
const head = new TextDecoder().decode(
bytes.subarray(0, Math.min(bytes.byteLength, 4096)),
);
const trimmed = head.replace(/^/, '').trimStart();
return trimmed.startsWith('<') && trimmed.includes('<svg');
} catch (_error) {
return false;
}
};
const SVG_RASTER_MAX_EDGE = 2048;
const SVG_RASTER_SCALE = 2;
const SVG_FAILURE_CAP = 1024;
export function createWebImageCaches(CK) {
const fullImageCache = new Map();
let fullImageCacheBytes = 0;
const thumbnailCache = new Map();
let thumbnailCacheBytes = 0;
const thumbnailFailures = new Set();
// SVG rasterizations in flight / permanently failed. Pending keys report
// decode success to the Rust side so the id is not negative-cached; paint
// keeps re-requesting until the browser's async decode lands the raster
// in `fullImageCache`.
const svgPending = new Set();
const svgFailures = new Set();
const rememberSvgFailure = (key) => {
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
// `<img>` (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);