collective

Browse / github.com/storytold/deckcraft / patch

File: save selected slides as a new file, split a presentation

Split a presentation into several files There is no way to split a presentation. Today the only option is to duplicate the file and delete slides by hand. Wanted: a command that splits the current deck, for example: - "Save selected slides as a new presentation…" - or "Split at section/slide N…", producing one file per section or per chunk of N slides. The new files should keep the theme, masters and media used by their slides. A split should not change the original deck. Related: #1103 (insert slides from other presentations).

Status
open
Score
0.00
Adopters
0 other operators
Author
Faabyy (imported from pull request #1108, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+651 −7 in 14 files
Review
pending
Shared
2026-10-28
Id
c9ce6070d7668a31a5bdf9a3

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/split.rs crates/engine/src/io.rs crates/engine/src/lib.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/app/files.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/body.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/thumbs.rs crates/ui-egui/src/tests.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -163,6 +163,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names | | 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) | | 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |+| 2026-10-21 | File: Save Selected Slides As… and Split Presentation… write part of a deck, or a deck split by N slides or by section, to new files (`file.saveSlidesAs`, `file.split`, #1104) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |@@ -305,6 +306,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged | | 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |+| 2026-10-21 | trivial | Progress log: save selected slides and split a presentation (#1104). Scores and milestone names unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs--- a/crates/engine/src/cmd.rs+++ b/crates/engine/src/cmd.rs@@ -25,6 +25,7 @@ pub mod selection; pub mod shape; pub mod slide; pub mod smartart;+pub mod split; pub mod table; pub mod text; pub mod three_d;@@ -166,6 +167,7 @@ pub fn command_specs() -> &'static [CommandSpec] {         v.extend(edit::specs());         v.extend(slide::specs());         v.extend(reuse::specs());+        v.extend(split::specs());         v.extend(insert::specs());         v.extend(smartart::specs());         v.extend(shape::specs());diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -479,7 +479,7 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<()> { fn save(s: &mut Session, p: &Value) -> Result<Value> {     let path = str_param(p, "path").map(String::from).or_else(|| s.active().and_then(|d| d.path.clone()));     let Some(path) = path else {-        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into() });+        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into(), params: json!({}) });         return Ok(json!({"needsPath": true}));     };     let format = str_param(p, "format").unwrap_or(format_for_path(&path));@@ -491,7 +491,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {  fn save_as(s: &mut Session, p: &Value) -> Result<Value> {     if str_param(p, "path").is_none() {-        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into() });+        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into(), params: json!({}) });         return Ok(json!({"needsPath": true}));     }     save(s, p)diff --git a/crates/engine/src/cmd/split.rs b/crates/engine/src/cmd/split.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/split.rs@@ -0,0 +1,485 @@+//! File ▸ Save Selected Slides As… and File ▸ Split Presentation…: write part of the open+//! presentation to new files, leaving the open one as it is.+//!+//! A part keeps what its slides need: the masters and layouts they use, the pictures and media+//! they point at, their sections and custom shows. Masters, media and embedded fonts nothing in+//! the part refers to are left out, so a part of a big deck is not as big as the deck.++use std::collections::HashSet;++use deckcraft_model::{Presentation, SlideId};+use serde_json::{Value, json};++use super::file::{parse_slide_range, save_bytes, writable_format_for_path};+use super::*;+use crate::{EngineError, Result, Session, media_refs};++/// Most files one split writes, so a typo (`every: 1` on a huge deck) can't fill a folder.+const MAX_PARTS: usize = 500;++pub fn specs() -> Vec<CommandSpec> {+    vec![+        cmd!(+            noundo "file.saveSlidesAs",+            "Save Selected Slides As…",+            ["File"],+            None,+            "{path, slides?: [index] (0-based) or a range text (1-based, such as 1-3;5) (default the selected slides), format?, overwrite?: bool (default false)} → {path, slides, bytes}",+            has_doc,+            save_slides_as+        ),+        cmd!(+            noundo "file.split",+            "Split Presentation…",+            ["File"],+            None,+            "{path (base name: parts are written as `<name>-1.<ext>`, `<name>-2.<ext>` …), every?: slides per file | bySection?: bool (one file per section), format?, overwrite?: bool (default false)} → {files: [{path, slides, bytes}]}",+            has_doc,+            split+        ),+    ]+}++/// A copy of `doc` holding only the slides at `indices` (in that order), with the masters, media,+/// sections and custom shows they need.+pub fn slice(doc: &Presentation, indices: &[usize]) -> Presentation {+    let mut out = doc.clone();+    out.slides = indices.iter().filter_map(|i| doc.slides.get(*i).cloned()).collect();+    let kept: HashSet<SlideId> = out.slides.iter().map(|s| s.id).collect();+    for sec in &mut out.sections {+        sec.slides.retain(|id| kept.contains(id));+    }+    out.sections.retain(|sec| !sec.slides.is_empty());+    for show in &mut out.custom_shows {+        show.slides.retain(|id| kept.contains(id));+    }+    out.custom_shows.retain(|show| !show.slides.is_empty());+    // Masters no slide uses go, unless they are marked to be preserved; the first stays so the+    // part is never left without one.+    let used: HashSet<_> = out.slides.iter().filter_map(|s| doc.layout(s.layout).map(|(m, _)| m.id)).collect();+    let first = out.masters.first().map(|m| m.id);+    out.masters = std::mem::take(&mut out.masters)+        .into_iter()+        .filter(|m| m.preserve || used.contains(&m.id) || (used.is_empty() && Some(m.id) == first))+        .collect();+    if out.masters.is_empty() {+        out.masters = doc.masters.iter().take(1).cloned().collect();+    }+    // Embedded fonts keep their bytes in `media`, so they count as references.+    let refs: HashSet<_> = media_refs::presentation_media(&out).into_iter().collect();+    out.media.retain(|m| refs.contains(&m.id));+    out+}++/// The slides a command works on: `slides` (indices or a range text), else the selection, else the+/// current slide.+fn slides_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {+    let st = s.doc()?;+    let count = st.doc.slides.len();+    let list = match p.get("slides") {+        Some(Value::String(text)) => parse_slide_range(text, count)?,+        Some(Value::Array(a)) => {+            let mut v: Vec<usize> = vec![];+            for x in a {+                let i = x.as_u64().and_then(|x| usize::try_from(x).ok()).ok_or_else(|| bad(cmd, "`slides` holds slide indices"))?;+                if i >= count {+                    return Err(bad(cmd, format!("no slide {i}")));+                }+                if !v.contains(&i) {+                    v.push(i);+                }+            }+            v+        }+        Some(Value::Null) | None => {+            let mut v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();+            if v.is_empty() && st.selection.slide < count {+                v.push(st.selection.slide);+            }+            v.sort_unstable();+            v+        }+        Some(_) => return Err(bad(cmd, "`slides` is a list of indices or a range such as \"1-3, 5\"")),+    };+    if list.is_empty() {+        return Err(bad(cmd, "there are no slides to save"));+    }+    Ok(list)+}++/// The format to write `path` in: `format` when given, else by the extension. An extension no+/// writer handles (`.odp`, none …) is an error rather than native bytes under a foreign name.+fn target_format(p: &Value, path: &str, cmd: &str) -> Result<String> {+    if let Some(f) = str_param(p, "format") {+        if !matches!(f, "deckcraft" | "pptx" | "potx" | "ppsx") {+            return Err(bad(cmd, format!("a part is saved as deckcraft, pptx, potx or ppsx, not {f}")));+        }+        return Ok(f.to_string());+    }+    match writable_format_for_path(path) {+        Some(f @ ("deckcraft" | "pptx" | "potx" | "ppsx")) => Ok(f.to_string()),+        _ => Err(bad(cmd, format!("“{path}” doesn't end in .pptx, .deckcraft, .potx or .ppsx"))),+    }+}++/// The slides `p` names (see `file.saveSlidesAs`) encoded for a file called `name`: how many+/// slides, and the bytes. Nothing is written; the web front end hands the bytes to the browser.+pub fn slides_file(s: &Session, p: &Value, name: &str) -> Result<(usize, Vec<u8>)> {+    let indices = slides_param(s, p, "file.saveSlidesAs")?;+    let format = target_format(p, name, "file.saveSlidesAs")?;+    Ok((indices.len(), save_bytes(&slice(&s.doc()?.doc, &indices), &format)?))+}++fn save_slides_as(s: &mut Session, p: &Value) -> Result<Value> {+    let Some(path) = str_param(p, "path").map(String::from) else {+        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveSlidesAs".into(), params: p.clone() });+        return Ok(json!({"needsPath": true}));+    };+    refuse_an_open_file(s, &path, "file.saveSlidesAs")?;+    refuse_an_existing_file(p, &path, "file.saveSlidesAs")?;+    let (slides, bytes) = slides_file(s, p, &path)?;+    crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;+    Ok(json!({"path": path, "slides": slides, "bytes": bytes.len()}))+}++/// An open presentation is unchanged in memory, so writing part of a deck over its file would+/// lose the rest of it on disk, or leave the open copy out of step with it. Every presentation+/// of the session counts, not only the active one.+fn refuse_an_open_file(s: &Session, path: &str, cmd: &str) -> Result<()> {+    match s.documents().iter().find(|d| d.path.as_deref().is_some_and(|open| crate::io::same_file(open, path))) {+        Some(d) => Err(bad(cmd, format!("{path} is open as “{}”; choose another file name", d.title()))),+        None => Ok(()),+    }+}++/// An existing file is only replaced with `overwrite: true` (a save picker that has asked the+/// user passes it).+fn refuse_an_existing_file(p: &Value, path: &str, cmd: &str) -> Result<()> {+    if !bool_or(p, "overwrite", false) && crate::io::file_len(path).is_some() {+        return Err(bad(cmd, format!("{path} exists already; pass `overwrite: true` to replace it")));+    }+    Ok(())+}++/// `(file stem, extension)` of the base path, e.g. `("/d/talk", "pptx")`.+fn split_path(path: &str) -> Option<(String, String)> {+    let dot = path.rfind('.')?;+    let sep = path.rfind(['/', '\\']).map_or(0, |i| i + 1);+    (dot > sep && dot + 1 < path.len()).then(|| (path[..dot].to_string(), path[dot + 1..].to_string()))+}++/// A section name made fit for a file name.+fn file_safe(name: &str) -> String {+    let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).collect();+    s.trim().chars().take(40).collect::<String>().trim().to_string()+}++/// The parts `file.split` makes of the active presentation for the base name `path`: the format,+/// and each part's file name with its slide indices. Nothing is encoded or written, so the web+/// front end can download the parts (encoded with [`part_bytes`]) under these names.+pub fn split_plan(s: &Session, p: &Value, path: &str) -> Result<(String, Vec<(String, Vec<usize>)>)> {+    let format = target_format(p, path, "file.split")?;+    let (stem, ext) = split_path(path).ok_or_else(|| bad("file.split", "`path` needs a file name with an extension"))?;+    let by_section = bool_or(p, "bySection", false);+    let every = usize_param(p, "every");+    if by_section == every.is_some() {+        return Err(bad("file.split", "give either `every` (slides per file) or `bySection`"));+    }+    if every == Some(0) {+        return Err(bad("file.split", "`every` must be at least 1"));+    }+    let doc = s.doc()?.doc.clone();+    // Each part: its slide indices and, for sections, its name.+    let mut parts: Vec<(Vec<usize>, Option<String>)> = vec![];+    if let Some(n) = every {+        let all: Vec<usize> = (0..doc.slides.len()).collect();+        parts = all.chunks(n).map(|c| (c.to_vec(), None)).collect();+    } else {+        // Slides outside every section (all of them, in a deck without sections) form a part of their own.+        let lead: Vec<usize> = doc.slides.iter().enumerate().filter(|(_, sl)| doc.section_of(sl.id).is_none()).map(|(i, _)| i).collect();+        if !lead.is_empty() {+            parts.push((lead, None));+        }+        for sec in &doc.sections {+            let idx: Vec<usize> = doc.slides.iter().enumerate().filter(|(_, sl)| sec.slides.contains(&sl.id)).map(|(i, _)| i).collect();+            if !idx.is_empty() {+                parts.push((idx, Some(sec.name.clone())));+            }+        }+        if parts.is_empty() {+            return Err(bad("file.split", "the presentation has no sections to split at"));+        }+    }+    if parts.len() > MAX_PARTS {+        return Err(bad("file.split", format!("that makes {} files; the most one split writes is {MAX_PARTS}", parts.len())));+    }+    if parts.len() < 2 {+        return Err(bad("file.split", "the presentation would stay in one piece; there is nothing to split"));+    }+    let width = parts.len().to_string().len();+    let named = parts+        .into_iter()+        .enumerate()+        .map(|(k, (idx, name))| {+            let file = match name.as_deref().map(file_safe).filter(|n| !n.is_empty()) {+                Some(l) => format!("{stem}-{:0width$}-{l}.{ext}", k + 1),+                None => format!("{stem}-{:0width$}.{ext}", k + 1),+            };+            (file, idx)+        })+        .collect();+    Ok((format, named))+}++/// The bytes of the part of `doc` holding the slides at `indices`, as `format`.+pub fn part_bytes(doc: &Presentation, indices: &[usize], format: &str) -> Result<Vec<u8>> {+    save_bytes(&slice(doc, indices), format)+}++fn split(s: &mut Session, p: &Value) -> Result<Value> {+    let Some(path) = str_param(p, "path").map(String::from) else {+        s.ui_requests.push(crate::UiRequest::PickFile { purpose: "splitPresentation".into(), params: p.clone() });+        return Ok(json!({"needsPath": true}));+    };+    let (format, parts) = split_plan(s, p, &path)?;+    // Every name is checked and everything encoded before the first file is written, so a bad+    // name or encoding leaves nothing behind. A write that fails later says which parts are written.+    for (file, _) in &parts {+        refuse_an_open_file(s, file, "file.split")?;+        refuse_an_existing_file(p, file, "file.split")?;+    }+    let doc = s.doc()?.doc.clone();+    let mut out: Vec<(String, usize, Vec<u8>)> = vec![];+    for (file, idx) in parts {+        let bytes = part_bytes(&doc, &idx, &format)?;+        out.push((file, idx.len(), bytes));+    }+    let mut files: Vec<Value> = vec![];+    for (file, slides, bytes) in out {+        if let Err(e) = crate::io::write_file(&file, &bytes) {+            let done: Vec<&str> = files.iter().filter_map(|f| f["path"].as_str()).collect();+            return Err(EngineError::Other(if done.is_empty() { e } else { format!("{e} (already written: {})", done.join(", ")) }));+        }+        files.push(json!({"path": file, "slides": slides, "bytes": bytes.len()}));+    }+    Ok(json!({"files": files}))+}++#[cfg(test)]+#[cfg(not(target_arch = "wasm32"))]+mod tests {+    #![allow(clippy::unwrap_used, clippy::expect_used)]++    use serde_json::{Value, json};++    use crate::Session;++    fn scratch(name: &str) -> std::path::PathBuf {+        let dir = std::env::temp_dir().join(format!("deckcraft-split-{}-{name}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).unwrap();+        dir+    }++    /// A deck of `n` slides.+    fn deck(n: usize) -> Session {+        let mut s = Session::with_new();+        while s.doc().unwrap().doc.slides.len() < n {+            s.execute("slide.new", &json!({})).unwrap();+        }+        s+    }++    fn open_count(path: &std::path::Path) -> usize {+        let mut s = Session::new();+        s.execute("file.open", &json!({"path": path.to_str().unwrap()})).unwrap();+        s.doc().unwrap().doc.slides.len()+    }++    #[test]+    fn save_selected_slides_writes_only_those_and_leaves_the_deck_alone() {+        let dir = scratch("save");+        let mut s = deck(5);+        s.execute("slide.selectSlides", &json!({"indices": [1, 3]})).unwrap();+        let out = dir.join("part.pptx");+        let r = s.execute("file.saveSlidesAs", &json!({"path": out.to_str().unwrap()})).unwrap();+        assert_eq!(r["slides"], 2);+        assert_eq!(open_count(&out), 2);+        assert_eq!(s.doc().unwrap().doc.slides.len(), 5, "the open deck keeps all its slides");+        assert!(s.doc().unwrap().path.is_none(), "saving a part doesn't rename the open deck");+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn save_slides_accepts_a_range_and_rejects_bad_input() {+        let dir = scratch("range");+        let mut s = deck(5);+        let out = dir.join("r.deckcraft");+        s.execute("file.saveSlidesAs", &json!({"path": out.to_str().unwrap(), "slides": "2-4"})).unwrap();+        assert_eq!(open_count(&out), 3);+        let p = dir.join("x.pptx");+        let p = p.to_str().unwrap();+        assert!(s.execute("file.saveSlidesAs", &json!({"path": p, "slides": [9]})).is_err());+        assert!(s.execute("file.saveSlidesAs", &json!({"path": p, "slides": "0-2"})).is_err());+        assert!(s.execute("file.saveSlidesAs", &json!({"path": dir.join("x.odp").to_str().unwrap()})).is_err());+        assert!(!std::path::Path::new(p).exists(), "a refused save writes nothing");+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn split_every_n_slides() {+        let dir = scratch("every");+        let mut s = deck(5);+        let base = dir.join("talk.pptx");+        let r = s.execute("file.split", &json!({"path": base.to_str().unwrap(), "every": 2})).unwrap();+        let files = r["files"].as_array().unwrap();+        assert_eq!(files.len(), 3);+        let counts: Vec<usize> = ["talk-1.pptx", "talk-2.pptx", "talk-3.pptx"].iter().map(|n| open_count(&dir.join(n))).collect();+        assert_eq!(counts, vec![2, 2, 1]);+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn split_by_section_names_the_files_after_the_sections() {+        let dir = scratch("sections");+        let mut s = deck(5);+        s.execute("section.add", &json!({"at": 0, "name": "Intro"})).unwrap();+        s.execute("section.add", &json!({"at": 2, "name": "Main/Part"})).unwrap();+        let base = dir.join("talk.deckcraft");+        s.execute("file.split", &json!({"path": base.to_str().unwrap(), "bySection": true})).unwrap();+        assert_eq!(open_count(&dir.join("talk-1-Intro.deckcraft")), 2);+        assert_eq!(open_count(&dir.join("talk-2-Main_Part.deckcraft")), 3);+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn split_refuses_before_writing_anything() {+        let dir = scratch("refuse");+        let mut s = deck(4);+        let base = dir.join("talk.pptx");+        let base = base.to_str().unwrap();+        std::fs::write(dir.join("talk-2.pptx"), "mine").unwrap();+        assert!(s.execute("file.split", &json!({"path": base, "every": 2})).is_err(), "an existing part is not overwritten");+        assert!(!dir.join("talk-1.pptx").exists(), "nothing was written before the refusal");+        assert_eq!(std::fs::read_to_string(dir.join("talk-2.pptx")).unwrap(), "mine");+        assert!(s.execute("file.split", &json!({"path": base})).is_err(), "needs every or bySection");+        assert!(s.execute("file.split", &json!({"path": base, "every": 0})).is_err());+        assert!(s.execute("file.split", &json!({"path": base, "every": 9})).is_err(), "one piece is not a split");+        assert!(s.execute("file.split", &json!({"path": base, "bySection": true})).is_err(), "no sections");+        s.execute("file.split", &json!({"path": base, "every": 2, "overwrite": true})).unwrap();+        assert_eq!(open_count(&dir.join("talk-2.pptx")), 2);+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn a_part_drops_sections_and_media_its_slides_do_not_use() {+        let mut s = deck(3);+        s.doc_mut().unwrap().doc = std::sync::Arc::new({+            let mut d = (*s.doc().unwrap().doc).clone();+            d.media.push(deckcraft_model::MediaItem {+                id: deckcraft_model::MediaId(900),+                name: "orphan.png".into(),+                content_type: "image/png".into(),+                data: std::sync::Arc::new(vec![1, 2, 3]),+                link: None,+            });+            d+        });+        s.execute("section.add", &json!({"at": 0, "name": "A"})).unwrap();+        s.execute("section.add", &json!({"at": 2, "name": "B"})).unwrap();+        let st = s.doc().unwrap();+        let part = super::slice(&st.doc, &[0, 1]);+        assert_eq!(part.slides.len(), 2);+        assert_eq!(part.sections.len(), 1);+        assert_eq!(part.sections[0].name, "A");+        assert!(part.media.iter().all(|m| m.name != "orphan.png"), "media nothing refers to is left out");+        assert!(!part.masters.is_empty());+        assert!(part.validate().is_empty(), "{:?}", part.validate());+    }++    #[test]+    fn saving_slides_over_the_open_presentation_is_refused() {+        let dir = scratch("self");+        let mut s = deck(3);+        let own = dir.join("talk.pptx");+        s.execute("file.saveAs", &json!({"path": own.to_str().unwrap()})).unwrap();+        let before = std::fs::read(&own).unwrap();+        s.execute("slide.selectSlides", &json!({"indices": [0]})).unwrap();+        // Also when the same file is spelled differently.+        let other_spelling = dir.join(".").join("talk.pptx");+        for path in [own.to_str().unwrap(), other_spelling.to_str().unwrap()] {+            assert!(s.execute("file.saveSlidesAs", &json!({"path": path})).is_err(), "{path}");+        }+        assert_eq!(std::fs::read(&own).unwrap(), before, "the presentation's file is untouched");+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn saving_slides_over_an_existing_file_needs_overwrite() {+        let dir = scratch("exists");+        let mut s = deck(3);+        let out = dir.join("part.pptx");+        std::fs::write(&out, "mine").unwrap();+        let path = out.to_str().unwrap();+        assert!(s.execute("file.saveSlidesAs", &json!({"path": path})).is_err());+        assert_eq!(std::fs::read_to_string(&out).unwrap(), "mine", "the existing file is untouched");+        s.execute("file.saveSlidesAs", &json!({"path": path, "overwrite": true})).unwrap();+        assert_eq!(open_count(&out), 1);+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn saving_slides_or_parts_over_another_open_presentation_is_refused() {+        let dir = scratch("other");+        let mut s = deck(4);+        // The first presentation is open as `talk-2.pptx`, the name a split's second part gets.+        let other = dir.join("talk-2.pptx");+        let other = other.to_str().unwrap();+        s.execute("file.saveAs", &json!({"path": other})).unwrap();+        let before = std::fs::read(other).unwrap();+        // A second presentation is active now; the first stays open with its file.+        s.execute("file.new", &json!({})).unwrap();+        while s.doc().unwrap().doc.slides.len() < 4 {+            s.execute("slide.new", &json!({})).unwrap();+        }+        assert!(s.execute("file.saveSlidesAs", &json!({"path": other, "overwrite": true})).is_err());+        let base = dir.join("talk.pptx");+        assert!(s.execute("file.split", &json!({"path": base.to_str().unwrap(), "every": 2, "overwrite": true})).is_err());+        assert!(!dir.join("talk-1.pptx").exists(), "nothing was written before the refusal");+        assert_eq!(std::fs::read(other).unwrap(), before, "the open presentation's file is untouched");+        let _ = std::fs::remove_dir_all(dir);+    }++    #[test]+    fn the_split_plan_names_the_parts_without_writing() {+        let mut s = deck(5);+        let (format, parts) = super::split_plan(&s, &json!({"every": 2}), "talk.pptx").unwrap();+        assert_eq!(format, "pptx");+        let names: Vec<&str> = parts.iter().map(|(n, _)| n.as_str()).collect();+        assert_eq!(names, ["talk-1.pptx", "talk-2.pptx", "talk-3.pptx"]);+        assert_eq!(parts[2].1, vec![4]);+        let bytes = super::part_bytes(&s.doc().unwrap().doc, &parts[0].1, &format).unwrap();+        assert!(!bytes.is_empty());+        s.execute("slide.selectSlides", &json!({"indices": [1, 2]})).unwrap();+        let (n, bytes) = super::slides_file(&s, &json!({}), "Talk (selected slides).pptx").unwrap();+        assert_eq!(n, 2);+        assert!(!bytes.is_empty());+        assert!(!std::path::Path::new("talk-1.pptx").exists());+    }++    #[test]+    fn a_request_for_a_file_name_keeps_the_other_parameters() {+        let mut s = deck(4);+        s.execute("file.saveSlidesAs", &json!({"slides": [1, 2]})).unwrap();+        s.execute("file.split", &json!({"every": 3})).unwrap();+        let params: Vec<(String, Value)> = s+            .ui_requests+            .iter()+            .filter_map(|r| match r {+                crate::UiRequest::PickFile { purpose, params } => Some((purpose.clone(), params.clone())),+                _ => None,+            })+            .collect();+        assert_eq!(params, vec![("saveSlidesAs".into(), json!({"slides": [1, 2]})), ("splitPresentation".into(), json!({"every": 3}))]);+    }+}diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -79,6 +79,21 @@ pub fn file_len(path: &str) -> Option<u64> {     std::fs::metadata(path).ok().map(|m| m.len()) } +/// Whether `a` and `b` name the same file: compared after resolving links and `..` when both exist,+/// as written otherwise.+#[cfg(not(target_arch = "wasm32"))]+pub fn same_file(a: &str, b: &str) -> bool {+    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {+        (Ok(x), Ok(y)) => x == y,+        _ => a == b,+    }+}++#[cfg(target_arch = "wasm32")]+pub fn same_file(a: &str, b: &str) -> bool {+    a == b+}+ #[cfg(target_arch = "wasm32")] pub fn file_len(_path: &str) -> Option<u64> {     Nonediff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -431,6 +431,9 @@ pub enum UiRequest {     },     PickFile {         purpose: String,+        /// The command's other parameters, to run it with once the path is chosen.+        #[serde(default)]+        params: Value,     },     /// Media playback for the UI host (`media.play` / `pause` / `stop` / `seek`): `action` is     /// `play`, `pause`, `toggle`, `stop` or `seek`; `ms` the media time to start from / seek to.diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs--- a/crates/ui-egui/src/app/commands.rs+++ b/crates/ui-egui/src/app/commands.rs@@ -230,6 +230,8 @@ impl SlideApp {             "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)),             "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),             "app.saveAsDialog" => self.save_as_dialog("deckcraft"),+            "app.saveSlidesAsDialog" => self.save_slides_as_dialog(json!({})),+            "app.splitDialog" => self.dialog = Some(dialogs::Dialog::new("splitPresentation")),             "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")),             "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")),             "app.browseThemeDialog" => {diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs--- a/crates/ui-egui/src/app/files.rs+++ b/crates/ui-egui/src/app/files.rs@@ -400,6 +400,71 @@ impl SlideApp {         }     } +    /// File › Save Selected Slides As…: write the selected slides to a new file (the save picker+    /// on the desktop, a download on the web). The open presentation stays as it is.+    /// `params` are further `file.saveSlidesAs` parameters (e.g. `slides`); without them the+    /// selected slides are saved.+    pub fn save_slides_as_dialog(&mut self, mut params: Value) {+        let Some(name) = self.session.active().map(|d| d.title()) else { return };+        if !params.is_object() {+            params = json!({});+        }+        let suggested = format!("{name} (selected slides).pptx");+        let Some(pick) = self.services.pick_save.as_mut() else {+            let bytes = deckcraft_engine::cmd::split::slides_file(&self.session, &params, &suggested).map(|(_, b)| b);+            self.download(&suggested, bytes);+            return;+        };+        if let Some(path) = pick(&suggested) {+            params["path"] = json!(path);+            // The save picker has already asked before replacing a file.+            params["overwrite"] = json!(true);+            match self.session.execute("file.saveSlidesAs", &params) {+                Ok(r) => self.set_status(format!("Saved {} slide(s) to {path}", r.get("slides").and_then(Value::as_u64).unwrap_or(0))),+                Err(e) => self.set_status(e.to_string()),+            }+        }+    }++    /// File › Split Presentation…: ask for the base file name, then write the parts next to it+    /// (on the web, each part is a download named the same way).+    /// `params` are the `file.split` parameters without `path`.+    pub fn split_presentation(&mut self, mut params: Value) {+        let Some(name) = self.session.active().map(|d| d.title()) else { return };+        let Some(pick) = self.services.pick_save.as_mut() else {+            self.download_split(&format!("{name}.pptx"), &params);+            return;+        };+        let Some(path) = pick(&format!("{name}.pptx")) else { return };+        if let Some(o) = params.as_object_mut() {+            o.insert("path".into(), json!(path));+        }+        match self.session.execute("file.split", &params) {+            Ok(r) => self.set_status(format!("Split into {} files", r.get("files").and_then(Value::as_array).map_or(0, Vec::len))),+            Err(e) => self.set_status(e.to_string()),+        }+    }++    /// The web's Split Presentation: every part is handed to the browser as a download named like+    /// the desktop's files (`<name>-1.pptx` …). Every part is encoded before the first download.+    fn download_split(&mut self, base: &str, params: &Value) {+        use deckcraft_engine::cmd::split::{part_bytes, split_plan};+        let encoded = (|| {+            let (format, parts) = split_plan(&self.session, params, base)?;+            let doc = self.session.doc()?.doc.clone();+            parts.into_iter().map(|(file, idx)| Ok((file, part_bytes(&doc, &idx, &format)?))).collect::<deckcraft_engine::Result<Vec<_>>>()+        })();+        match encoded {+            Ok(files) => {+                let n = files.len();+                if files.into_iter().fold(true, |ok, (file, bytes)| self.download(&file, Ok(bytes)) && ok) {+                    self.set_status(format!("Split into {n} files"));+                }+            }+            Err(e) => self.set_status(e.to_string()),+        }+    }+     /// Save every presentation with unsaved changes (asking for a location where needed).     /// Returns false if one wasn't saved (cancelled or failed).     pub fn save_all(&mut self) -> bool {@@ -437,11 +502,14 @@ impl SlideApp {                 UiRequest::Pane { id } => self.ui.pane = Some(id),                 UiRequest::StartShow { from } => self.start_show(from, false),                 UiRequest::Message { text } => self.set_status(text),-                UiRequest::PickFile { purpose } => {-                    if purpose == "saveAs" {-                        self.save_as_dialog("deckcraft");-                    }-                }+                UiRequest::PickFile { purpose, params } => match purpose.as_str() {+                    "saveAs" => self.save_as_dialog("deckcraft"),+                    "saveSlidesAs" => self.save_slides_as_dialog(params),+                    // A request that already says how to split only needs the file name.+                    "splitPresentation" if params.get("every").is_some() || params.get("bySection").is_some() => self.split_presentation(params),+                    "splitPresentation" => self.dialog = Some(dialogs::Dialog::new("splitPresentation")),+                    _ => {}+                },                 UiRequest::Media { action, shape, ms } => {                     let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor };                     // The command resolved `shape` on the editor's slide, also while a show runs.diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str {         "outline" => "Slides from Outline",         "renameSection" => "Rename Section",         "reuseSlides" => "Reuse Slides",+        "splitPresentation" => "Split Presentation",         "renameShape" => "Rename",         "renameLayout" => "Rename Layout",         "paragraph" => "Paragraph",diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs--- a/crates/ui-egui/src/dialogs/body.rs+++ b/crates/ui-egui/src/dialogs/body.rs@@ -628,6 +628,34 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {             }             cancel         }+        "splitPresentation" => {+            ui.label("Write the presentation as several files. The open presentation stays as it is.");+            let by = d.get("by", "every");+            for (l, id) in [("A file every N slides", "every"), ("A file per section", "section")] {+                if ui.radio(by == id, l).clicked() {+                    d.fields.insert("by".into(), id.into());+                }+            }+            if by == "every" {+                field(ui, d, "Slides per file:", "every", "10");+            }+            let (ok, cancel) = buttons(ui, "Split…");+            if ok {+                let params = if by == "every" {+                    match d.get("every", "10").trim().parse::<usize>() {+                        Ok(n) if n > 0 => json!({"every": n}),+                        _ => {+                            app.set_status("Slides per file must be a number from 1".to_string());+                            return false;+                        }+                    }+                } else {+                    json!({"bySection": true})+                };+                app.split_presentation(params);+            }+            ok || cancel+        }         "renameSection" | "renameShape" | "renameLayout" => {             let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();             field(ui, d, "Name:", "name", &cur);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -133,6 +133,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[     ("app.reuseSlidesDialog", "Reuse Slides…", None, "{keepFormatting?: bool}"),     ("app.insertVideoDialog", "Video from File…", None, "{}"),     ("app.saveAsDialog", "Save As…", None, "{}"),+    ("app.saveSlidesAsDialog", "Save Selected Slides As…", None, "{}"),+    ("app.splitDialog", "Split Presentation…", None, "{}"),     ("app.exportDialog", "Export…", None, "{}"),     ("app.browseThemeDialog", "Browse for Themes…", None, "{}"),     ("app.compareDialog", "Compare…", None, "{}"),diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -169,6 +169,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {                 ("Close", "app.closePresentation"),                 ("Save", "file.save"),                 ("Save As…", "app.saveAsDialog"),+                ("Save Selected Slides As…", "app.saveSlidesAsDialog"),+                ("Split Presentation…", "app.splitDialog"),                 ("Export…", "app.exportDialog"),                 ("Print…", "app.print"),                 ("-", ""),diff --git a/crates/ui-egui/src/panels/thumbs.rs b/crates/ui-egui/src/panels/thumbs.rs--- a/crates/ui-egui/src/panels/thumbs.rs+++ b/crates/ui-egui/src/panels/thumbs.rs@@ -247,6 +247,10 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) {         let _ = app.run("slide.delete", json!({}));         ui.close();     }+    if ui.button("Save Slides As…").clicked() {+        app.save_slides_as_dialog(json!({}));+        ui.close();+    }     ui.separator();     if ui.button("Add Section").clicked() {         let _ = app.run("section.add", json!({"at": i}));diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs--- a/crates/ui-egui/src/tests.rs+++ b/crates/ui-egui/src/tests.rs@@ -88,6 +88,36 @@ fn web_png_export_can_leave_the_background_transparent() {     assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency"); } +/// Without a save picker (the web), Save Selected Slides As and Split Presentation download+/// their files instead of stopping.+#[test]+fn web_saves_selected_slides_and_split_parts_as_downloads() {+    use std::cell::RefCell;+    use std::rc::Rc;+    let got: Rc<RefCell<Vec<(String, Vec<u8>)>>> = Rc::default();+    let log = got.clone();+    let services = Services {+        download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))),+        ..Default::default()+    };+    let mut app = SlideApp::new(Session::with_new(), services);+    for _ in 0..3 {+        app.session.execute("slide.new", &json!({})).unwrap();+    }+    app.session.execute("slide.selectSlides", &json!({"indices": [1, 2]})).unwrap();+    app.save_slides_as_dialog(json!({}));+    app.split_presentation(json!({"every": 2}));+    let got = got.borrow();+    let names: Vec<&str> = got.iter().map(|(n, _)| n.as_str()).collect();+    assert_eq!(names.len(), 3, "{names:?}");+    assert!(names[0].ends_with(" (selected slides).pptx"), "{names:?}");+    assert!(names[1].ends_with("-1.pptx") && names[2].ends_with("-2.pptx"), "{names:?}");+    let slides = |b: &[u8]| deckcraft_engine::cmd::file::open_presentation("x.pptx", b).unwrap().slides.len();+    assert_eq!(slides(&got[0].1), 2);+    assert_eq!(slides(&got[1].1), 2);+    assert_eq!(slides(&got[2].1), 2);+}+ fn title_text(app: &SlideApp) -> String {     let d = app.session.active().unwrap();     let sh = &d.doc.slides[0].shapes[0];