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:
parent
72792315e9
commit
a9c1401e1a
150
Cargo.lock
generated
150
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue