collective

Browse / github.com/storytold/deckcraft / patch

Added svg rendering feature

Failing to render Special shapes and SVGs Hello, # ENV: - am on fedora 44 (everything) with niri and noctalia, kernel 7.2.11-200.fc44.x86_64 # issue -The program fails to render the Vector Graphics and Grouped AutoShapes on slides 14, 15, and 16. Instead of displaying the diagrams, it is rendering them as blank spaces. <img width="1920" height="1080" alt="Image" src="https://github.com/user-attachments/assets/9d9902fe-7fdf-46b2-913a-422e8783c986" /> The PPTX file to replicate the issue: [svg-samples.pptx](https://github.com/user-attachments/files/33880596/svg-group-repro.pptx) # LOGS ╰ deckcraft svg-group-repro.pptx 2026-10

Status
open
Score
0.00
Adopters
0 other operators
Author
DevSidd2006 (imported from pull request #1328, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+1075 −49 in 18 files
Review
pending
Shared
2026-10-28
Id
a48051ee7ce791b455b9fc0c

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml crates/engine/src/cmd/artistic.rs crates/engine/src/cmd/background.rs crates/engine/src/cmd/compress.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/reuse.rs crates/engine/src/cmd/shape.rs crates/engine/src/media_refs.rs crates/engine/src/tests.rs crates/model/src/style.rs crates/pptx/src/opc.rs crates/pptx/src/read/dml.rs crates/pptx/src/write/dml.rs crates/pptx/tests/it/roundtrip.rs crates/pptx/tests/svg_pictures.rs crates/render/Cargo.toml crates/render/src/images.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -924,6 +924,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]]+name = "data-url"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"+ [[package]] name = "deckcraft" version = "0.11.1"@@ -1020,7 +1026,7 @@ version = "0.11.1" dependencies = [  "epaint_default_fonts",  "harfrust 0.13.3",- "kurbo",+ "kurbo 0.13.1",  "log",  "skrifa 0.47.0",  "unicode-bidi",@@ -1041,7 +1047,7 @@ dependencies = [ name = "deckcraft-geom" version = "0.11.1" dependencies = [- "kurbo",+ "kurbo 0.13.1",  "serde", ] @@ -1110,7 +1116,7 @@ name = "deckcraft-metafile" version = "0.11.1" dependencies = [  "image",- "kurbo",+ "kurbo 0.13.1",  "thiserror 2.0.21", ] @@ -1145,7 +1151,7 @@ dependencies = [  "deckcraft-text",  "hayro-syntax",  "krilla",- "kurbo",+ "kurbo 0.13.1",  "log",  "serde",  "thiserror 2.0.21",@@ -1180,8 +1186,9 @@ dependencies = [  "deckcraft-model",  "deckcraft-text",  "image",- "kurbo",+ "kurbo 0.13.1",  "log",+ "resvg",  "skrifa 0.47.0",  "vello_common",  "vello_cpu",@@ -1747,6 +1754,18 @@ dependencies = [  "bytemuck", ] +[[package]]+name = "fontdb"+version = "0.23.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"+dependencies = [+ "log",+ "slotmap",+ "tinyvec",+ "ttf-parser",+]+ [[package]] name = "foreign-types" version = "0.5.0"@@ -1935,7 +1954,7 @@ dependencies = [  "hashbrown 0.17.1",  "log",  "peniko",- "png",+ "png 0.18.1",  "skrifa 0.44.0",  "smallvec",  "vello_common",@@ -2353,7 +2372,7 @@ dependencies = [  "image-webp",  "moxcms",  "num-traits",- "png",+ "png 0.18.1",  "tiff",  "zune-core",  "zune-jpeg",@@ -2369,6 +2388,12 @@ dependencies = [  "quick-error", ] +[[package]]+name = "imagesize"+version = "0.13.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"+ [[package]] name = "imagesize" version = "0.14.0"@@ -2552,10 +2577,10 @@ dependencies = [  "float-cmp",  "gif",  "image-webp",- "imagesize",+ "imagesize 0.14.0",  "indexmap",  "pdf-writer",- "png",+ "png 0.18.1",  "rayon",  "rustc-hash 2.1.3",  "rustybuzz",@@ -2563,12 +2588,23 @@ dependencies = [  "skrifa 0.42.1",  "smallvec",  "subsetter",- "tiny-skia-path",+ "tiny-skia-path 0.12.0",  "xmp-writer",  "yoke",  "zune-jpeg", ] +[[package]]+name = "kurbo"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"+dependencies = [+ "arrayvec",+ "euclid",+ "smallvec",+]+ [[package]] name = "kurbo" version = "0.13.1"@@ -3372,7 +3408,7 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [  "bytemuck",  "color",- "kurbo",+ "kurbo 0.13.1",  "linebender_resource_handle",  "smallvec", ]@@ -3437,6 +3473,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.13"@@ -3499,6 +3541,19 @@ dependencies = [  "time", ] +[[package]]+name = "png"+version = "0.17.16"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"+dependencies = [+ "bitflags 1.3.2",+ "crc32fast",+ "fdeflate",+ "flate2",+ "miniz_oxide 0.8.9",+]+ [[package]] name = "png" version = "0.18.1"@@ -3778,6 +3833,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]]+name = "resvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"+dependencies = [+ "log",+ "pico-args",+ "rgb",+ "svgtypes",+ "tiny-skia",+ "usvg",+]+ [[package]] name = "rfd" version = "0.17.2"@@ -3805,6 +3874,21 @@ dependencies = [  "windows-sys 0.61.2", ] +[[package]]+name = "rgb"+version = "0.8.53"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"+dependencies = [+ "bytemuck",+]++[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"+ [[package]] name = "ring" version = "0.17.14"@@ -4157,6 +4241,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.4"@@ -4277,6 +4370,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 = "subsetter"@@ -4284,13 +4380,21 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" dependencies = [- "kurbo",+ "kurbo 0.13.1",  "rustc-hash 2.1.3",  "skrifa 0.42.1",  "write-fonts", ]  [[package]]+name = "svgtypes"+version = "0.15.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"+dependencies = [+ "kurbo 0.11.3",+ "siphasher",+] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -4620,6 +4724,29 @@ dependencies = [ ]  [[package]]+name = "tiny-skia"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"+dependencies = [+ "arrayref",+ "arrayvec",+ "bytemuck",+ "cfg-if",+ "log",+ "png 0.17.16",+ "tiny-skia-path 0.11.4",+]++[[package]]+name = "tiny-skia-path"+version = "0.11.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"+dependencies = [+ "arrayref",+ "bytemuck",+ "strict-num", name = "time" version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -4670,6 +4797,12 @@ dependencies = [  "zerovec", ] +[[package]]+name = "tinyvec"+version = "1.13.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"+ [[package]] name = "tokio" version = "1.53.2"@@ -4867,6 +5000,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]]+name = "unicode-vo"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"+ [[package]] name = "unicode-width" version = "0.2.2"@@ -4921,6 +5060,31 @@ dependencies = [ ]  [[package]]+name = "usvg"+version = "0.45.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"+dependencies = [+ "base64",+ "data-url",+ "flate2",+ "fontdb",+ "imagesize 0.13.0",+ "kurbo 0.11.3",+ "log",+ "pico-args",+ "roxmltree",+ "rustybuzz",+ "simplecss",+ "siphasher",+ "strict-num",+ "svgtypes",+ "tiny-skia-path 0.11.4",+ "unicode-bidi",+ "unicode-script",+ "unicode-vo",+ "xmlwriter",+] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -4954,7 +5118,7 @@ dependencies = [  "guillotiere",  "log",  "peniko",- "png",+ "png 0.18.1",  "smallvec",  "thiserror 2.0.21", ]@@ -4970,7 +5134,7 @@ dependencies = [  "glifo",  "hashbrown 0.17.1",  "ordered-channel",- "png",+ "png 0.18.1",  "rayon",  "thread_local",  "vello_common",@@ -5877,7 +6041,7 @@ checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" dependencies = [  "font-types 0.11.3",  "indexmap",- "kurbo",+ "kurbo 0.13.1",  "log",  "read-fonts 0.39.2", ]@@ -5951,6 +6115,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]]+name = "xmlwriter"+version = "0.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"+ [[package]] name = "xmp-writer" version = "0.3.3"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -59,6 +59,7 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }+resvg = { version = "0.45.1", default-features = false, features = ["text"] } craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }diff --git a/crates/engine/src/cmd/artistic.rs b/crates/engine/src/cmd/artistic.rs--- a/crates/engine/src/cmd/artistic.rs+++ b/crates/engine/src/cmd/artistic.rs@@ -148,18 +148,19 @@ fn artistic_effect(s: &mut Session, p: &Value) -> Result<Value> {     let ids = targets(s, p)?;     let st = s.doc()?;     // (shape, original picture, its bytes and name)-    let mut work: Vec<(ShapeId, MediaId, Option<(Vec<u8>, String)>)> = Vec::new();+    let mut work: Vec<(ShapeId, MediaId, Option<MediaId>, Option<(Vec<u8>, String)>)> = Vec::new();     for id in ids {         let Some(ShapeKind::Picture { fill }) = st.shape(id).map(|sh| &sh.kind) else { continue };         let original = fill.artistic.as_ref().map_or(fill.media, |a| a.original);+        let svg = fill.artistic.as_ref().map_or(fill.svg, |a| a.svg);         let item = st.doc.media(original).map(|m| (m.data.to_vec(), m.name.clone()));-        work.push((id, original, item));+        work.push((id, original, svg, item));     }     if work.is_empty() {         return Err(bad("picture.artisticEffect", "select a picture"));     }-    let mut results: Vec<(ShapeId, MediaId, Option<(String, Vec<u8>)>)> = Vec::new();-    for (id, original, item) in work {+    let mut results: Vec<(ShapeId, MediaId, Option<MediaId>, Option<(String, Vec<u8>)>)> = Vec::new();+    for (id, original, svg, item) in work {         let processed = match (&effect, item) {             (None, _) => None,             (Some(e), Some((data, name))) => {@@ -167,30 +168,33 @@ fn artistic_effect(s: &mut Session, p: &Value) -> Result<Value> {             }             (Some(_), None) => return Err(bad("picture.artisticEffect", "the picture's data is missing")),         };-        results.push((id, original, processed));+        results.push((id, original, svg, processed));     }     let effect2 = effect.clone();     s.edit(move |doc, sel| {-        let mut set: Vec<(ShapeId, MediaId, Option<MediaId>)> = Vec::new();-        for (id, original, processed) in &results {+        let mut set: Vec<(ShapeId, MediaId, Option<MediaId>, Option<MediaId>)> = Vec::new();+        for (id, original, svg, processed) in &results {             let m = match processed {                 Some((name, png)) => Some(doc.add_media(name, "image/png", png.clone()).ok_or_else(|| bad("picture.artisticEffect", NO_ID_SPACE))?),                 None => None,             };-            set.push((*id, *original, m));+            set.push((*id, *original, *svg, m));         }         let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("picture.artisticEffect", "no slide"))?;-        for (id, original, m) in set {+        for (id, original, svg, m) in set {             if let Some(sh) = deckcraft_model::find_shape_mut(list, id)                 && let ShapeKind::Picture { fill } = &mut sh.kind             {                 match (m, &effect2) {                     (Some(m), Some(e)) => {                         fill.media = m;-                        fill.artistic = Some(Artistic { effect: e.clone(), original });+                        fill.artistic = Some(Artistic { effect: e.clone(), original, svg });+                        // The effect is drawn on the raster picture; an SVG would show without it.+                        fill.svg = None;                     }                     _ => {                         fill.media = original;+                        fill.svg = svg;                         fill.artistic = None;                     }                 }diff --git a/crates/engine/src/cmd/background.rs b/crates/engine/src/cmd/background.rs--- a/crates/engine/src/cmd/background.rs+++ b/crates/engine/src/cmd/background.rs@@ -175,6 +175,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {                 && let ShapeKind::Picture { fill } = &mut sh.kind             {                 fill.media = media;+                fill.svg = None;             }         }         Ok(())diff --git a/crates/engine/src/cmd/compress.rs b/crates/engine/src/cmd/compress.rs--- a/crates/engine/src/cmd/compress.rs+++ b/crates/engine/src/cmd/compress.rs@@ -189,6 +189,10 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {                 if let Some((id, new_crop, _)) = hit {                     changed.push(media);                     fill.media = id;+                    // A cropped-away area is gone from the raster but not from the SVG.+                    if new_crop != crop {+                        fill.svg = None;+                    }                     fill.crop = new_crop;                     count += 1;                     touched = true;@@ -301,7 +305,7 @@ mod tests {             let slide = Arc::make_mut(doc.slides.first_mut().ok_or_else(|| bad("test", "no slide"))?);             let xfrm = Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 36.0));             let with_effect =-                PictureFill { media: shown, artistic: Some(Artistic { effect: "artisticBlur".into(), original }), ..Default::default() };+                PictureFill { media: shown, artistic: Some(Artistic { effect: "artisticBlur".into(), original, svg: None }), ..Default::default() };             slide.shapes.push(Shape { id: ShapeId(70_001), xfrm, kind: ShapeKind::Picture { fill: with_effect }, ..Default::default() });             slide.shapes.push(Shape {                 id: ShapeId(70_002),diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -75,6 +75,10 @@ fn slides_scope(s: &Session, p: &Value) -> bool {         || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) } +pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {+    crate::media_refs::visit::shapes(shapes, &mut |id| out.push(*id));+}+ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {     let st = s.doc()?;     // Text selection.@@ -95,6 +99,11 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {         let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();         let mut mids = vec![];         for sl in &slides {+            media_of_shapes(&sl.shapes, &mut mids);+            if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {+                mids.push(pf.media);+                mids.extend(pf.svg);+            }             mids.extend(crate::media_refs::slide_media(sl));         }         let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();@@ -233,6 +242,16 @@ fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {     rec(shapes, f, 0); } +/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).+fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {+    if map.is_empty() {+        return;+    }+    crate::media_refs::visit_mut::shapes(shapes, &mut |id| {+        if let Some(n) = map.get(id) {+            *id = *n;+        }+    }); /// Rewrite a media id the clipboard used (`map`: clipboard id to destination id). fn remap_media(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {     move |id: &mut MediaId| {@@ -296,6 +315,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {                 sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);                 let map = reid("edit.paste", doc, &mut sl.shapes)?;                 remap_animations(&mut sl.animations, &map);+                remap_media(&mut sl.shapes, &media);+                if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background {+                    if let Some(n) = media.get(&pf.media) {+                        pf.media = *n;+                    }+                    pf.svg = pf.svg.map(|s| media.get(&s).copied().unwrap_or(s));+                }                 crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));                 if doc.layout(sl.layout).is_none() {                     sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs--- a/crates/engine/src/cmd/reuse.rs+++ b/crates/engine/src/cmd/reuse.rs@@ -314,7 +314,7 @@ mod tests {                 kind: ShapeKind::Picture {                     fill: PictureFill {                         media: id(0),-                        artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1) }),+                        artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1), svg: None }),                         ..Default::default()                     },                 },diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs--- a/crates/engine/src/cmd/shape.rs+++ b/crates/engine/src/cmd/shape.rs@@ -397,6 +397,453 @@ fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {     ok() } +fn crop(s: &mut Session, p: &Value) -> Result<Value> {+    let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];+    edit_geometry(s, p, "picture.crop", |sh| {+        if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {+            for (i, v) in c.iter().enumerate() {+                if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {+                    *slot = v.clamp(-10.0, 0.95);+                }+            }+        }+        Ok(())+    })+}++fn picture_adjust(s: &mut Session, p: &Value) -> Result<Value> {+    edit_shapes(s, p, "picture.adjust", |sh| {+        if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {+            let a = &mut fill.adjust;+            if bool_or(p, "reset", false) {+                *a = Default::default();+            }+            if let Some(v) = f64_param(p, "brightness") {+                a.brightness = v.clamp(-1.0, 1.0);+            }+            if let Some(v) = f64_param(p, "contrast") {+                a.contrast = v.clamp(-1.0, 1.0);+            }+            if let Some(v) = f64_param(p, "saturation") {+                a.saturation = Some(v.clamp(0.0, 4.0));+            }+            if let Some(v) = bool_param(p, "grayscale") {+                a.grayscale = v;+            }+            if let Some(v) = f64_param(p, "transparency") {+                fill.alpha = Some((1.0 - v).clamp(0.0, 1.0));+            }+        }+        Ok(())+    })+}++fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {+    edit_shapes(s, p, "picture.reset", |sh| {+        if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {+            fill.adjust = Default::default();+            fill.crop = [0.0; 4];+            fill.alpha = None;+            if let Some(a) = fill.artistic.take() {+                fill.media = a.original;+                fill.svg = a.svg;+            }+        }+        sh.effects = None;+        sh.line = None;+        Ok(())+    })+}++fn picture_change(s: &mut Session, p: &Value) -> Result<Value> {+    let (name, bytes) = super::insert::media_bytes(p, "picture.change")?;+    let ct = super::insert::content_type(&name, &bytes);+    let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?;+    edit_shapes(s, p, "picture.change", |sh| {+        if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {+            fill.media = media;+            fill.crop = [0.0; 4];+            fill.artistic = None;+            fill.svg = None;+        }+        Ok(())+    })+}++fn media_options(s: &mut Session, p: &Value) -> Result<Value> {+    edit_shapes(s, p, "media.options", |sh| {+        if let deckcraft_model::ShapeKind::Media(m) = &mut sh.kind {+            if let Some(v) = bool_param(p, "autoplay") {+                m.autoplay = v;+            }+            if let Some(v) = bool_param(p, "loop") {+                m.loop_play = v;+            }+            if let Some(v) = bool_param(p, "rewind") {+                m.rewind = v;+            }+            if let Some(v) = bool_param(p, "acrossSlides") {+                m.play_across_slides = v;+            }+            if let Some(v) = bool_param(p, "hide") {+                m.hide_while_not_playing = v;+            }+            if let Some(v) = bool_param(p, "fullScreen") {+                m.full_screen = v;+            }+            if let Some(v) = f64_param(p, "volume") {+                m.volume = v.clamp(0.0, 1.0);+            }+            let ms = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32);+            if let Some(v) = ms("trimStart") {+                m.trim_start_ms = v;+            }+            if let Some(v) = ms("trimEnd") {+                m.trim_end_ms = v;+            }+            if let Some(v) = ms("fadeIn") {+                m.fade_in_ms = v;+            }+            if let Some(v) = ms("fadeOut") {+                m.fade_out_ms = v;+            }+        }+        Ok(())+    })?;+    Ok(json!({}))+}++fn sites(s: &mut Session, p: &Value) -> Result<Value> {+    let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;+    let st = s.doc()?;+    let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;+    Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))+}++fn connect(s: &mut Session, p: &Value) -> Result<Value> {+    let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {+        return Err(bad("shape.connect", "missing `from` / `to`"));+    };+    let st = s.doc()?;+    let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();+    let (a, b) = (site_list(from), site_list(to));+    if a.is_empty() || b.is_empty() {+        return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));+    }+    let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len());+    let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) {+        (Some(i), Some(j)) => (i, j),+        (fi, fj) => {+            let mut best = (f64::MAX, 0, 0);+            for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) {+                for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) {+                    let d = (*pa - *pb).hypot();+                    if d < best.0 {+                        best = (d, i, j);+                    }+                }+            }+            (best.1, best.2)+        }+    };+    let (p0, p1) = (a[ia], b[ib]);+    let id = match id_param(p, "id", "shape.connect")? {+        Some(id) => id,+        None => {+            let preset = str_param(p, "preset").unwrap_or("straightConnector1");+            let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?;+            deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32)+        }+    };+    s.edit(|doc, sel| {+        let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?;+        let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?;+        if !sh.is_line() {+            return Err(bad("shape.connect", "`id` is not a line or connector"));+        }+        sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0));+        sh.kind = deckcraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) };+        Ok(())+    })?;+    Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))+}++/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics.+pub fn freeform_path(pts: &[deckcraft_geom::Point], closed: bool, smooth: bool) -> String {+    let f = |v: f64| {+        let s = format!("{:.2}", v);+        s.trim_end_matches('0').trim_end_matches('.').to_string()+    };+    let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y));+    let n = pts.len();+    if smooth && n > 2 {+        let at = |i: isize| -> deckcraft_geom::Point {+            if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] }+        };+        let segs = if closed { n } else { n - 1 };+        for i in 0..segs as isize {+            let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));+            let c1 = p1 + (p2 - p0) / 6.0;+            let c2 = p2 - (p3 - p1) / 6.0;+            d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y));+        }+    } else {+        for q in &pts[1..] {+            d += &format!(" L {} {}", f(q.x), f(q.y));+        }+    }+    if closed {+        d += " Z";+    }+    d+}++fn freeform(s: &mut Session, p: &Value) -> Result<Value> {+    let pts: Vec<deckcraft_geom::Point> = p+        .get("points")+        .and_then(Value::as_array)+        .map(|a| {+            a.iter()+                .filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))+                .filter(|q| q.x.is_finite() && q.y.is_finite())+                .take(20_000)+                .collect()+        })+        .unwrap_or_default();+    if pts.len() < 2 {+        return Err(bad("shape.freeform", "need at least two points"));+    }+    let closed = bool_or(p, "closed", false) && pts.len() > 2;+    let smooth = bool_or(p, "smooth", false);+    let mut bb = deckcraft_geom::Rect::from_points(pts[0], pts[0]);+    for q in &pts {+        bb = bb.union_pt(*q);+    }+    let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));+    let local: Vec<deckcraft_geom::Point> = pts.iter().map(|q| deckcraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect();+    let d = freeform_path(&local, closed, smooth);+    // Closed shapes take the default shape style, open paths the line style.+    let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?;+    let id = deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32);+    s.edit(|doc, sel| {+        let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?;+        let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?;+        sh.geom = Geom::Custom {+            paths: vec![deckcraft_model::CustomPath {+                w,+                h,+                d: d.clone(),+                fill: if closed { deckcraft_geom::preset::FillMode::Norm } else { deckcraft_geom::preset::FillMode::None },+                stroke: true,+            }],+        };+        sh.xfrm = Some(deckcraft_geom::Xfrm::new(bb.x0, bb.y0, w, h));+        sh.kind = deckcraft_model::ShapeKind::Shape;+        sh.name = format!("Freeform {}", id.0);+        Ok(())+    })?;+    Ok(json!({"id": id.0}))+}++#[cfg(test)]+mod size_position_tests {+    use serde_json::json;++    use crate::Session;++    fn deck() -> (Session, u32) {+        let mut s = Session::with_new();+        s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+        let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32;+        (s, id)+    }++    fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) {+        let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();+        let b = &v["box"];+        (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0))+    }++    fn undos(s: &Session) -> usize {+        s.doc().unwrap().history.undo.len()+    }++    #[test]+    fn sets_position_size_and_rotation_in_one_undo() {+        let (mut s, id) = deck();+        let n = undos(&s);+        s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap();+        assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0));+        assert_eq!(undos(&s), n + 1);+        assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");+        s.execute("edit.undo", &json!({})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));+    }++    #[test]+    fn lock_aspect_and_scale_share_one_step() {+        let (mut s, id) = deck();+        s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0));+        s.execute("edit.undo", &json!({})).unwrap();+        s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));+        s.execute("edit.undo", &json!({})).unwrap();+        s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0));+        s.execute("edit.undo", &json!({})).unwrap();+        s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));+        // An explicit width wins over a scale on the same axis.+        s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap();+        assert_eq!(geom(&mut s, id).2, 80.0);+    }++    #[test]+    fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() {+        let (mut s, id) = deck();+        let n = undos(&s);+        s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0));+        assert_eq!(undos(&s), n + 1);+        let n = undos(&s);+        s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap();+        assert_eq!(undos(&s), n, "identical geometry is not an undo step");+    }++    #[test]+    fn no_fields_opens_the_dialog_without_editing() {+        let (mut s, id) = deck();+        let n = undos(&s);+        s.execute("shape.sizePosition", &json!({})).unwrap();+        assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));+        assert_eq!(undos(&s), n);+        let req = s.ui_requests.last().unwrap();+        match req {+            crate::UiRequest::Dialog { id, params } => {+                assert_eq!(id, "sizePosition");+                assert_eq!(params["w"], 200.0);+                assert_eq!(params["h"], 100.0);+                assert_eq!(params["x"], 40.0);+                assert_eq!(params["rot"], 0.0);+                assert_eq!(params["scaleW"], 100.0);+                assert_eq!(params["lockAspect"], false);+            }+            other => panic!("expected a dialog, got {other:?}"),+        }+    }++    #[test]+    fn bad_fields_do_not_edit() {+        let (mut s, id) = deck();+        let n = undos(&s);+        for p in [+            json!({"w": "wide"}),+            json!({"h": -5.0}),+            json!({"scaleW": 0.0}),+            json!({"x": 1e9}),+            json!({"rot": "spin"}),+            json!({"lockAspect": "yes"}),+        ] {+            let err = s.execute("shape.sizePosition", &p).unwrap_err();+            assert!(err.to_string().contains("sizePosition"), "{err}");+            assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));+            assert_eq!(undos(&s), n);+        }+    }++    #[test]+    fn scale_is_per_shape_and_nothing_selected_is_disabled() {+        let (mut s, a) = deck();+        let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;+        s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap();+        assert_eq!(geom(&mut s, a).2, 400.0);+        assert_eq!(geom(&mut s, a).3, 100.0);+        assert_eq!(geom(&mut s, b).2, 80.0);+        assert_eq!(geom(&mut s, b).3, 80.0);+        assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");+        s.execute("edit.select", &json!({"ids": []})).unwrap();+        let err = s.execute("shape.sizePosition", &json!({})).unwrap_err();+        assert!(err.to_string().contains("nothing selected"), "{err}");+    }+}++/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape+/// outlined in the stroke's colour and width; the rest stays ink. One undo step.+fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {+    use crate::inkshape::{Recognized, recognize};+    use deckcraft_model::{Shape, ShapeId, ShapeKind};+    let shapes = s.doc()?.shapes();+    let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {+        return Ok(json!({"shapes": [], "kept": 0}));+    };+    let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));+    let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };+    let found: Vec<Option<Recognized>> = strokes+        .iter()+        .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })+        .collect();+    if found.iter().all(Option::is_none) {+        return Ok(json!({"shapes": [], "kept": strokes.len()}));+    }+    s.edit(|doc, sel| {+        let mut new = vec![];+        let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;+        let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };+        let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };+        let all = std::mem::take(strokes);+        let mut kept = vec![];+        let mut made = vec![];+        for (st, r) in all.into_iter().zip(found.iter()) {+            let Some(r) = r else {+                kept.push(st);+                continue;+            };+            let (preset, xfrm) = match *r {+                Recognized::Line { x0, y0, x1, y1 } => {+                    ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))+                }+                Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),+                Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),+                Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),+            };+            made.push((preset, xfrm, st.color, st.width));+        }+        let remaining = kept.len();+        *strokes = kept;+        if remaining == 0 {+            list.remove(pos);+        }+        for (preset, xfrm, color, width) in made {+            let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?);+            let name = format!(+                "{} {}",+                match preset {+                    "line" => "Straight Connector",+                    "rect" => "Rectangle",+                    "ellipse" => "Oval",+                    _ => "Isosceles Triangle",+                },+                id.0+            );+            let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;+            list.push(Shape {+                id,+                name,+                xfrm: Some(xfrm),+                geom: Geom::preset(preset),+                fill: Some(Fill::None),+                line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),+                kind: ShapeKind::Shape,+                ..Default::default()+            });+            new.push(id);+        }+        sel.shapes = new.clone();+        Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))+    })+}+ #[cfg(test)] mod tests {     #![allow(clippy::unwrap_used)]diff --git a/crates/engine/src/media_refs.rs b/crates/engine/src/media_refs.rs--- a/crates/engine/src/media_refs.rs+++ b/crates/engine/src/media_refs.rs@@ -30,6 +30,12 @@ macro_rules! walkers {             f(&$($m)? p.media);             if let Some(a) = &$($m)? p.artistic {                 f(&$($m)? a.original);+                if let Some(s) = &$($m)? a.svg {+                    f(s);+                }+            }+            if let Some(s) = &$($m)? p.svg {+                f(s);             }         } @@ -337,7 +343,7 @@ mod tests {         let mut s = (*p.slides.first().cloned().unwrap_or_default()).clone();         let pic = Shape {             kind: ShapeKind::Picture {-                fill: PictureFill { artistic: Some(Artistic { effect: "artisticBlur".into(), original: MediaId(9002) }), ..pf(9001) },+                fill: PictureFill { artistic: Some(Artistic { effect: "artisticBlur".into(), original: MediaId(9002), svg: None }), ..pf(9001) },             },             ..Default::default()         };diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -2185,6 +2185,67 @@ mod copy_remap {         assert_eq!(table_pictures(&b, &shape(&b, 0, tb)), deck_pictures("b"));     } +    #[test]+    fn a_pasted_picture_brings_its_svg_along() {+        let mut a = Session::with_new();+        let mut b = Session::with_new();+        let (pa, ..) = populate(&mut a, b"raster");+        populate(&mut b, b"deck b picture");+        {+            let doc = Arc::make_mut(&mut a.doc_mut().unwrap().doc);+            let svg = doc.add_media("p.svg", "image/svg+xml", b"<svg/>".to_vec()).expect("media");+            let slide = Arc::make_mut(&mut doc.slides[0]);+            let Some(Shape { kind: ShapeKind::Picture { fill }, .. }) = slide.shapes.iter_mut().find(|x| x.id == pa) else { panic!() };+            fill.svg = Some(svg);+        }+        select(&mut a, &[pa]);+        a.execute("edit.copy", &json!({})).unwrap();+        b.clipboard = a.clipboard.clone();+        b.doc_mut().unwrap().selection.shapes.clear();+        let r = b.execute("edit.paste", &json!({})).unwrap();+        let new = shape(&b, 0, ShapeId(r["ids"][0].as_u64().unwrap() as u32));+        let ShapeKind::Picture { fill } = &new.kind else { panic!("not a picture") };+        let svg = fill.svg.and_then(|m| b.doc().unwrap().doc.media(m)).map(|m| m.data.as_ref().clone());+        assert_eq!(svg.as_deref(), Some(&b"<svg/>"[..]));+    }++    #[test]+    fn a_pasted_table_cell_remaps_its_svg_fill() {+        use deckcraft_model::style::{Fill, PictureFill};+        use deckcraft_model::table::{Cell, Row, Table};+        let mut a = Session::with_new();+        let mut b = Session::with_new();+        let (id, ..) = populate(&mut a, b"raster a");+        populate(&mut b, b"raster b");+        {+            let doc = Arc::make_mut(&mut a.doc_mut().unwrap().doc);+            let svg = doc.add_media("cell.svg", "image/svg+xml", b"<svg/>".to_vec()).expect("media");+            let slide = Arc::make_mut(&mut doc.slides[0]);+            let sh = slide.shapes.iter_mut().find(|s| s.id == id).expect("shape");+            sh.kind = ShapeKind::Table(Table {+                cols: vec![80.0],+                rows: vec![Row {+                    height: 40.0,+                    cells: vec![Cell {+                        fill: Some(Fill::Picture(PictureFill { media: svg, svg: Some(svg), ..Default::default() })),+                        ..Default::default()+                    }],+                }],+                ..Default::default()+            });+        }+        select(&mut a, &[id]);+        a.execute("edit.copy", &json!({})).unwrap();+        b.clipboard = a.clipboard.clone();+        b.doc_mut().unwrap().selection.shapes.clear();+        let r = b.execute("edit.paste", &json!({})).unwrap();+        let copied = shape(&b, 0, ShapeId(r["ids"][0].as_u64().unwrap() as u32));+        let ShapeKind::Table(table) = copied.kind else { panic!("not a table") };+        let Some(Fill::Picture(fill)) = &table.rows[0].cells[0].fill else { panic!("not a picture fill") };+        assert_eq!(fill.svg, Some(fill.media));+        assert_eq!(b.doc().unwrap().doc.media(fill.media).unwrap().data.as_slice(), b"<svg/>");+    }+     #[test]     fn copied_connectors_follow_copied_ends_and_detach_from_the_rest() {         let mut s = Session::with_new();@@ -3763,6 +3824,51 @@ fn artistic_effects_apply_change_remove_and_round_trip() {     assert_eq!((fill(&s).media, fill(&s).artistic), (original, None)); } +#[test]+fn artistic_effect_removal_and_reset_restore_svg() {+    use deckcraft_model::ShapeKind;+    let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 30, 40, 255]));+    let mut png = Vec::new();+    image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+    let mut s = Session::with_new();+    s.execute("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap();+    let svg = {+        let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);+        let svg = doc.add_media("a.svg", "image/svg+xml", b"<svg/>".to_vec()).unwrap();+        let slide = Arc::make_mut(&mut doc.slides[0]);+        let sh = slide.shapes.iter_mut().find(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).unwrap();+        let ShapeKind::Picture { fill } = &mut sh.kind else { panic!() };+        fill.svg = Some(svg);+        svg+    };+    for clear in ["none", "reset"] {+        s.execute("picture.artisticEffect", &json!({"effect": "artisticBlur"})).unwrap();+        let fill = match &s.doc().unwrap().selected_shapes()[0].kind {+            ShapeKind::Picture { fill } => fill,+            _ => panic!(),+        };+        assert_eq!(fill.artistic.as_ref().and_then(|a| a.svg), Some(svg));+        if clear == "none" {+            s.execute("picture.artisticEffect", &json!({"effect": "none"})).unwrap();+        } else {+            s.execute("picture.reset", &json!({})).unwrap();+        }+        let fill = match &s.doc().unwrap().selected_shapes()[0].kind {+            ShapeKind::Picture { fill } => fill,+            _ => panic!(),+        };+        assert_eq!(fill.svg, Some(svg));+        assert!(fill.artistic.is_none());+        let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap();+        let reopened = deckcraft_pptx::import(&bytes).unwrap();+        let restored = reopened.slides.iter().flat_map(|sl| &sl.shapes).find_map(|sh| match &sh.kind {+            ShapeKind::Picture { fill } => fill.svg.and_then(|id| reopened.media(id)),+            _ => None,+        });+        assert_eq!(restored.map(|m| m.data.as_slice()), Some(&b"<svg/>"[..]));+    }+}+ #[test] fn handout_master_view_edits_and_round_trips() {     use deckcraft_model::PhType;diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs--- a/crates/model/src/style.rs+++ b/crates/model/src/style.rs@@ -147,6 +147,10 @@ pub struct PictureFill {     /// picture; the original is kept to change or remove the effect.     #[serde(skip_serializing_if = "Option::is_none")]     pub artistic: Option<Artistic>,+    /// The picture's SVG, from Office's SVG extension (`asvg:svgBlip`), written back the same+    /// way. When the file has no raster fallback, `media` is this SVG too.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub svg: Option<MediaId>, }  /// An applied artistic effect: its `a14` name (e.g. `artisticBlur`) and the original picture.@@ -155,6 +159,9 @@ pub struct PictureFill { pub struct Artistic {     pub effect: String,     pub original: MediaId,+    /// SVG paired with the original picture, restored when the effect is removed.+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub svg: Option<MediaId>, }  /// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency).diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs--- a/crates/pptx/src/opc.rs+++ b/crates/pptx/src/opc.rs@@ -27,6 +27,7 @@ pub const NS_P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/ma pub const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main"; pub const NS_P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"; pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main";+pub const NS_ASVG: &str = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"; /// Modern (threaded) comments, Office 2018/8 schema. pub const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/main"; diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs--- a/crates/pptx/src/read/dml.rs+++ b/crates/pptx/src/read/dml.rs@@ -128,9 +128,11 @@ pub fn gradient(f: &El) -> Gradient { /// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media. pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {     let blip = bf.child("blip");-    let media = blip.and_then(|b| media_ref(imp, part, b));-    let media = media?;-    let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() };+    // Office's SVG extension carries the vector picture, with or without a raster fallback in+    // the blip's own `r:embed`.+    let svg = blip.and_then(|b| b.child("extLst")).and_then(|x| x.find("svgBlip")).and_then(|s| media_ref(imp, part, s));+    let media = blip.and_then(|b| media_ref(imp, part, b)).or(svg)?;+    let mut pf = PictureFill { media, svg, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() };     if let Some(layer) = blip.and_then(|b| b.child("extLst")).and_then(|x| x.find("imgLayer"))         && let Some(effect) = layer             .elements()@@ -140,7 +142,7 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option<PictureFill> {             .find(|n| n.starts_with("artistic"))         && let Some(original) = media_ref(imp, part, layer)     {-        pf.artistic = Some(deckcraft_model::style::Artistic { effect, original });+        pf.artistic = Some(deckcraft_model::style::Artistic { effect, original, svg: None });     }     if let Some(b) = blip {         for e in b.elements() {diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs--- a/crates/pptx/src/write/dml.rs+++ b/crates/pptx/src/write/dml.rs@@ -99,7 +99,12 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil     // An image that lives outside the package is a linked picture: `a:blip` names it through     // `r:link`; `r:embed` may only reference a part inside the package.     let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());-    let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));+    // An SVG rides in Office's SVG extension. Without a raster fallback the blip itself names no+    // image, as PowerPoint writes it.+    let svg_rel = pf.svg.and_then(|m| x.media_rel(o, m, "image"));+    let svg_linked = pf.svg.and_then(|m| x.p.media(m)).is_some_and(|m| m.data.is_empty() && m.link.is_some());+    let svg_only = svg_rel.is_some() && pf.svg == Some(pf.media);+    let rid = if svg_only { None } else { Some(x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o))) };     w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));     let adj = &pf.adjust;     let has_kids = pf.alpha.is_some()@@ -110,8 +115,13 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil         || adj.clear_color.is_some()         || adj.bilevel.is_some()         || adj.alpha_inv-        || pf.artistic.is_some();-    let a = if linked { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) };+        || pf.artistic.is_some()+        || svg_rel.is_some();+    let a = match &rid {+        None => A::new(),+        Some(rid) if linked => A::new().a("r:link", rid),+        Some(rid) => A::new().a("r:embed", rid),+    };     if has_kids {         w.open("a:blip", a);         if let Some(c) = adj.clear_color {@@ -147,21 +157,30 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil         }         // Artistic effect: the blip is the processed picture; the original rides in an         // `a14:imgLayer` with the effect's name, as PowerPoint writes it.-        if let Some(art) = &pf.artistic-            && art.effect.starts_with("artistic")-            && art.effect.chars().all(|c| c.is_ascii_alphanumeric())-            && let Some(orig) = x.media_rel(o, art.original, "image")-        {+        let art = pf+            .artistic+            .as_ref()+            .filter(|art| art.effect.starts_with("artistic") && art.effect.chars().all(|c| c.is_ascii_alphanumeric()))+            .and_then(|art| x.media_rel(o, art.original, "image").map(|orig| (art, orig)));+        if art.is_some() || svg_rel.is_some() {             w.open0("a:extLst");-            w.open("a:ext", A::new().a("uri", "{BEBA8EAE-BF5A-486C-A8C5-ECC9F3942E4B}"));-            w.open("a14:imgProps", A::new().a("xmlns:a14", crate::opc::NS_A14));-            w.open("a14:imgLayer", A::new().a("r:embed", &orig));-            w.open0("a14:imgEffect");-            w.empty0(&format!("a14:{}", art.effect));-            w.close("a14:imgEffect");-            w.close("a14:imgLayer");-            w.close("a14:imgProps");-            w.close("a:ext");+            if let Some((art, orig)) = &art {+                w.open("a:ext", A::new().a("uri", "{BEBA8EAE-BF5A-486C-A8C5-ECC9F3942E4B}"));+                w.open("a14:imgProps", A::new().a("xmlns:a14", crate::opc::NS_A14));+                let original_linked = x.p.media(art.original).is_some_and(|m| m.data.is_empty() && m.link.is_some());+                w.open("a14:imgLayer", A::new().a(if original_linked { "r:link" } else { "r:embed" }, orig));+                w.open0("a14:imgEffect");+                w.empty0(&format!("a14:{}", art.effect));+                w.close("a14:imgEffect");+                w.close("a14:imgLayer");+                w.close("a14:imgProps");+                w.close("a:ext");+            }+            if let Some(svg) = &svg_rel {+                w.open("a:ext", A::new().a("uri", "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"));+                w.empty("asvg:svgBlip", A::new().a("xmlns:asvg", crate::opc::NS_ASVG).a(if svg_linked { "r:link" } else { "r:embed" }, svg));+                w.close("a:ext");+            }             w.close("a:extLst");         }         w.close("a:blip");diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs--- a/crates/pptx/tests/it/roundtrip.rs+++ b/crates/pptx/tests/it/roundtrip.rs@@ -243,6 +243,7 @@ pub fn rich_deck() -> Presentation {             adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() },             upright: false,             artistic: None,+            svg: None,         },     };     let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0);diff --git a/crates/pptx/tests/svg_pictures.rs b/crates/pptx/tests/svg_pictures.rsnew file mode 100644--- /dev/null+++ b/crates/pptx/tests/svg_pictures.rs@@ -0,0 +1,94 @@+//! Pictures that carry an SVG through Office's SVG extension (`asvg:svgBlip`).++use std::io::{Cursor, Read, Write};++use deckcraft_geom::Xfrm;+use deckcraft_model::style::PictureFill;+use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};++const SVG: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#c2185b"/></svg>"##;++fn png() -> Vec<u8> {+    let mut out = Cursor::new(vec![]);+    image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 2, image::Rgb([194, 24, 91])))+        .write_to(&mut out, image::ImageFormat::Png)+        .expect("png");+    out.into_inner()+}++/// A deck with one picture showing an SVG with a PNG fallback, exported.+fn deck() -> Vec<u8> {+    let mut p = defaults::new_presentation(None);+    let media = p.add_media("step.png", "image/png", png()).expect("png");+    let svg = p.add_media("step.svg", "image/svg+xml", SVG.to_vec()).expect("svg");+    let id = ShapeId(p.alloc_id().expect("ids"));+    let first = p.slides[0].id;+    p.slide_mut(first).expect("slide").shapes.push(Shape {+        id,+        name: "Step".into(),+        xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),+        kind: ShapeKind::Picture { fill: PictureFill { media, svg: Some(svg), ..Default::default() } },+        ..Default::default()+    });+    deckcraft_pptx::export(&p).expect("export")+}++/// `bytes` with `edit` applied to the first slide.+fn edit_slide(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {+    let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");+    let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));+    let o = zip::write::SimpleFileOptions::default();+    for i in 0..z.len() {+        let mut f = z.by_index(i).expect("entry");+        let mut d = vec![];+        f.read_to_end(&mut d).expect("read");+        let name = f.name().to_string();+        if name == "ppt/slides/slide1.xml" {+            d = edit(String::from_utf8(d).expect("utf8")).into_bytes();+        }+        out.start_file(name, o).expect("start");+        out.write_all(&d).expect("write");+    }+    out.finish().expect("finish").into_inner()+}++fn slide_xml(bytes: &[u8]) -> String {+    let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");+    let mut out = String::new();+    z.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut out).expect("utf8");+    out+}++/// The picture named Step and the bytes of its image and SVG.+fn step(p: &Presentation) -> (Vec<u8>, Option<Vec<u8>>) {+    let s = p.slides[0].shapes.iter().find(|s| s.name == "Step").expect("picture");+    let ShapeKind::Picture { fill } = &s.kind else { panic!("not a picture: {:?}", s.kind) };+    let data = |m| p.media(m).map(|m| m.data.as_ref().clone()).expect("media");+    (data(fill.media), fill.svg.map(data))+}++#[test]+fn an_svg_with_a_png_fallback_round_trips() {+    let bytes = deck();+    let xml = slide_xml(&bytes);+    assert!(xml.contains("<asvg:svgBlip"), "{xml}");+    assert!(xml.contains("<a:blip r:embed="), "{xml}");+    let (image, svg) = step(&deckcraft_pptx::import(&bytes).expect("import"));+    assert_eq!((image, svg), (png(), Some(SVG.to_vec())));+}++#[test]+fn an_svg_without_a_fallback_stays_a_picture() {+    // Decks can name the SVG only, with no raster image in the blip itself.+    let bytes = edit_slide(&deck(), |x| {+        let start = x.find("<a:blip r:embed=\"").expect("blip");+        let end = start + x[start..].find('>').expect("end");+        format!("{}<a:blip>{}", &x[..start], &x[end + 1..])+    });+    let q = deckcraft_pptx::import(&bytes).expect("import");+    assert_eq!(step(&q), (SVG.to_vec(), Some(SVG.to_vec())));+    let again = deckcraft_pptx::export(&q).expect("export");+    let xml = slide_xml(&again);+    assert!(xml.contains("<p:pic>") && xml.contains("<a:blip><a:extLst>") && xml.contains("<asvg:svgBlip"), "{xml}");+    assert_eq!(step(&deckcraft_pptx::import(&again).expect("import")), (SVG.to_vec(), Some(SVG.to_vec())));+}diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml--- a/crates/render/Cargo.toml+++ b/crates/render/Cargo.toml@@ -19,6 +19,7 @@ deckcraft-text = { workspace = true } vello_cpu = { workspace = true } vello_common = { workspace = true } image = { workspace = true }+resvg = { workspace = true } log = { workspace = true } kurbo = { workspace = true } skrifa = { workspace = true }diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs--- a/crates/render/src/images.rs+++ b/crates/render/src/images.rs@@ -13,6 +13,8 @@ const BUDGET: usize = 512 << 20; /// header before decoding and again before the conversion to RGBA, which the decoder's own /// allocation limit doesn't cover (a grey image converts to four times its decoded size). const MAX_RGBA: u64 = 512 << 20;+const SVG_MAX_BYTES: usize = 32 << 20;+const SVG_MAX_SIDE: u32 = 4096;  /// Pictures that failed to decode, remembered so they aren't retried every frame: at most this /// many, holding at most this many encoded bytes (oldest forgotten first). Larger failed inputs@@ -131,9 +133,91 @@ fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {     if crate::metafile::is_metafile(bytes) {         return crate::metafile::rasterize(bytes, side);     }+    if looks_like_svg(bytes) {+        return decode_svg(bytes, side);+    }     decode_raster(bytes, MAX_RGBA) } +fn looks_like_svg(bytes: &[u8]) -> bool {+    let head = bytes.get(..bytes.len().min(4096)).unwrap_or(bytes);+    contains_ascii_case_insensitive(head, b"<svg") || contains_ascii_case_insensitive(head, b"<?xml")+}++fn svg_options() -> resvg::usvg::Options<'static> {+    static FONTDB: std::sync::OnceLock<Arc<resvg::usvg::fontdb::Database>> = std::sync::OnceLock::new();+    let fontdb = FONTDB.get_or_init(|| {+        let mut db = resvg::usvg::fontdb::Database::new();+        for bytes in deckcraft_fonts::bundled() {+            db.load_font_data(bytes.to_vec());+        }+        db.set_serif_family(deckcraft_fonts::LAST_RESORT_FAMILY);+        db.set_sans_serif_family(deckcraft_fonts::LAST_RESORT_FAMILY);+        Arc::new(db)+    });+    resvg::usvg::Options {+        resources_dir: None,+        image_href_resolver: resvg::usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },+        fontdb: fontdb.clone(),+        font_family: deckcraft_fonts::LAST_RESORT_FAMILY.to_string(),+        ..resvg::usvg::Options::default()+    }+}++fn svg_tree(bytes: &[u8]) -> Option<resvg::usvg::Tree> {+    if bytes.len() > SVG_MAX_BYTES {+        return None;+    }+    std::str::from_utf8(bytes).ok()?;+    for forbidden in ["<script", "<image", "<feimage", "@font-face", "font-face-uri", "javascript:", "url(http", "url(https", "url(file"] {+        if contains_ascii_case_insensitive(bytes, forbidden.as_bytes()) {+            return None;+        }+    }+    resvg::usvg::Tree::from_data(bytes, &svg_options()).ok()+}++fn contains_ascii_case_insensitive(bytes: &[u8], needle: &[u8]) -> bool {+    !needle.is_empty() && bytes.windows(needle.len()).any(|window| window.iter().zip(needle).all(|(a, b)| a.eq_ignore_ascii_case(b)))+}++fn svg_size(bytes: &[u8]) -> Option<(f64, f64)> {+    let tree = svg_tree(bytes)?;+    let size = tree.size();+    let (w, h) = (f64::from(size.width()), f64::from(size.height()));+    (w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 && w * h * 4.0 <= MAX_RGBA as f64).then_some((w, h))+}++fn decode_svg(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {+    let tree = svg_tree(bytes)?;+    let size = tree.size();+    let (w, h) = (f64::from(size.width()), f64::from(size.height()));+    if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {+        return None;+    }+    let scale = f64::from(side.min(SVG_MAX_SIDE)).min(f64::from(SVG_MAX_SIDE)) / w.max(h);+    let width = (w * scale).round().clamp(1.0, f64::from(SVG_MAX_SIDE)) as u32;+    let height = (h * scale).round().clamp(1.0, f64::from(SVG_MAX_SIDE)) as u32;+    if !fits(width, height, MAX_RGBA) {+        return None;+    }+    let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?;+    let transform = resvg::tiny_skia::Transform::from_scale(width as f32 / w as f32, height as f32 / h as f32);+    resvg::render(&tree, transform, &mut pixmap.as_mut());+    let mut rgba = pixmap.data().to_vec();+    for px in rgba.as_chunks_mut::<4>().0 {+        let a = px[3] as u16;+        if a == 0 {+            px[..3].fill(0);+        } else if a < 255 {+            for c in &mut px[..3] {+                *c = ((*c as u16 * 255 + a / 2) / a).min(255) as u8;+            }+        }+    }+    image::RgbaImage::from_raw(width, height, rgba)+}+ impl Cache {     /// Remember that `bytes` doesn't decode, within [`BAD_MAX`] and [`BAD_BUDGET`].     fn mark_bad(&mut self, addr: usize, bytes: &Arc<Vec<u8>>) {@@ -184,6 +268,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {     if crate::metafile::is_metafile(bytes) {         return None;     }+    if looks_like_svg(bytes) {+        return svg_size(bytes);+    }     let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;     if w == 0 || h == 0 {         return None;@@ -588,6 +675,55 @@ mod tests {         assert_eq!((a.width(), a.height()), (20, 10));     } +    #[test]+    fn svg_decodes_with_intrinsic_size_and_transparency() {+        let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20">+            <rect width="40" height="20" fill="#c2185b"/>+            <circle cx="10" cy="10" r="5" fill="#ffffff"/>+        </svg>"##;+        assert_eq!(natural_size(svg), Some((40.0, 20.0)));+        let img = decode(svg).expect("SVG decodes");+        assert_eq!(img.dimensions(), (1024, 512));+        assert_eq!(img.get_pixel(800, 256).0, [194, 24, 91, 255]);+        assert_eq!(img.get_pixel(0, 0).0, [194, 24, 91, 255]);+    }++    #[test]+    fn svg_rendering_supports_viewbox_gradient_and_transform() {+        let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10">+            <defs><linearGradient id="g"><stop offset="0" stop-color="#000000"/><stop offset="1" stop-color="#ffffff"/></linearGradient></defs>+            <rect width="20" height="10" fill="url(#g)"/>+            <rect x="2" y="2" width="4" height="4" fill="#ff0000" transform="rotate(45 4 4)"/>+        </svg>"##;+        let bytes = Arc::new(svg.to_vec());+        let pm = decode_for(&bytes, 64.0, &PictureAdjust::default()).expect("SVG renders");+        assert_eq!((pm.width(), pm.height()), (64, 32));+        assert!(pm.data().iter().any(|p| p.a != 0));+    }++    #[test]+    fn svg_detection_survives_a_cut_utf8_prefix_and_text_renders() {+        let mut svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40"><desc>"#.to_vec();+        svg.extend(std::iter::repeat_n(b' ', 4095 - svg.len()));+        svg.extend_from_slice("é</desc><text x=\"4\" y=\"30\" font-family=\"Arial,Helvetica,Arial\" font-size=\"28\">A</text></svg>".as_bytes());+        assert!(looks_like_svg(&svg));+        let img = decode(&svg).expect("SVG text decodes");+        assert!(img.pixels().any(|p| p[3] > 0), "bundled font draws the label");+    }++    #[test]+    fn svg_with_scripts_or_external_resources_is_rejected() {+        for svg in [+            br##"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"##.as_slice(),+            br##"<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.com/x.png"/></svg>"##.as_slice(),+            br##"<svg xmlns="http://www.w3.org/2000/svg"><image href="relative.png"/></svg>"##.as_slice(),+            br##"<svg xmlns="http://www.w3.org/2000/svg"><image href="file:///tmp/picture.png"/></svg>"##.as_slice(),+            br##"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="20"/>"##.as_slice(),+        ] {+            assert!(decode(svg).is_none(), "unsafe or invalid SVG decoded");+        }+    }+     #[test]     fn duotone_maps_luminance_between_its_two_colours() {         use deckcraft_color::Rgba;