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.rs
new 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;
