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
@@ -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"@@ -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"] }@@ -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; } }@@ -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(())@@ -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),@@ -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();@@ -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() }, },@@ -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)]@@ -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() };@@ -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;@@ -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).@@ -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"; @@ -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() {@@ -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");@@ -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);new file mode 100644@@ -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())));+}@@ -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 }@@ -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;