Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/cmd/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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)
Expand Down
485 changes: 485 additions & 0 deletions crates/engine/src/cmd/split.rs

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions crates/engine/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
None
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions crates/ui-egui/src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => {
Expand Down
78 changes: 73 additions & 5 deletions crates/ui-egui/src/app/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &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 {
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions crates/ui-egui/src/dialogs/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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::<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
}

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);
Expand Down
2 changes: 2 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "{}"),
Expand Down
2 changes: 2 additions & 0 deletions crates/ui-egui/src/menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
("-", ""),
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/panels/thumbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down
30 changes: 30 additions & 0 deletions crates/ui-egui/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading