diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69f..3e34a835c 100644 --- 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 index 59513c6b0..075696078 100644 --- 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 index f21f2a3ae..ed3dcce00 100644 --- 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 { 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 { fn save_as(s: &mut Session, p: &Value) -> Result { 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.rs new file mode 100644 index 000000000..c64233769 --- /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::{CommandSpec, bad, bool_or, cmd, has_doc, str_param, usize_param}; +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 { + 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 `-1.`, `-2.` …), 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 = 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> { + 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 = 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 = 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 { + 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)> { + 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 { + 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::().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)>)> { + 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, Option)> = vec![]; + if let Some(n) = every { + let all: Vec = (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 = 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 = 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> { + save_bytes(&slice(doc, indices), format) +} + +fn split(s: &mut Session, p: &Value) -> Result { + 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)> = vec![]; + for (file, idx) in parts { + let bytes = part_bytes(&doc, &idx, &format)?; + out.push((file, idx.len(), bytes)); + } + let mut files: Vec = 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 = ["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 index 7d21bb4b8..2b4e2563c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -79,6 +79,21 @@ pub fn file_len(path: &str) -> Option { 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 { None diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3f645cca..9b7c4af85 100644 --- 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 index 216dd30d3..6267a7e3c 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -232,6 +232,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 index 0f3c944f7..a7da41958 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -403,6 +403,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, ¶ms, &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", ¶ms) { + 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"), ¶ms); + 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", ¶ms) { + 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 (`-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::>>() + })(); + 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 { @@ -440,11 +505,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 index ed33b9cd6..2324c274d 100644 --- 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 index d458c2632..222ed92ec 100644 --- a/crates/ui-egui/src/dialogs/body.rs +++ b/crates/ui-egui/src/dialogs/body.rs @@ -39,6 +39,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "altText" => dlg_alt_text(app, ui, d), "outline" => dlg_outline(app, ui, d), "reuseSlides" => dlg_reuse_slides(app, ui, d), + "splitPresentation" => dlg_split_presentation(app, ui, d), "renameSection" | "renameShape" | "renameLayout" => dlg_rename_section(app, ui, d), "font" => font_dialog(app, ui, d), "paragraph" => paragraph_dialog(app, ui, d), @@ -689,6 +690,35 @@ fn dlg_reuse_slides(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { cancel } +fn dlg_split_presentation(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + 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::() { + 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 +} + fn dlg_rename_section(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { 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 index 30b5a0cd5..15b3710c7 100644 --- 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 index f78e25267..4f33909bf 100644 --- 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 index 25be3e8c2..b8a5833e9 100644 --- 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 index 0672d8c24..5c2be2f34 100644 --- 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)>>> = 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];