Files
deckcraft/crates/engine/src/cmd/file.rs
T
Brandon ThomasandClaude Opus 5.5 1660982e58 File tab: PowerPoint's File view (Home, New, Open, Info, Save As, Print, Export, Close) (#76) (#344)
* Engine: file.info and PowerPoint-style print slide ranges (#76)

file.info reports what File › Info shows (name, path, format, unsaved
changes, slide and hidden-slide counts, sections, size on disk,
properties). file.export's PDF `slides` also takes the range typed in
PowerPoint's Print page ("1-3, 5", 1-based); bad or out-of-range text is
an error instead of a silent full export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UI: the File tab (Backstage) like PowerPoint's (#76)

File (first ribbon tab) or Alt+F opens a full-window view in place of the
ribbon, slides and status bar: a side menu (Back, Home, New, Open, Info,
Save, Save As, Print, Export, Close; Account and Options at the bottom)
and the chosen page. Esc or Back returns; Up/Down walk the side menu and
show each page as it gets the focus; Enter runs Save/Close/Options.
Laid out after PowerPoint for Windows as observed: 160-pt menu a step
darker than the page, outline icons, current page in the accent colour.

The start screen is now File › Home (as in PowerPoint), with only Home,
New and Open at the top and no Back. Pages call existing commands and
file helpers: file.new {theme} and the sample deck (Home, New); recent
files with search, Browse and Recover Unsaved (Open); file.info and
file.properties, Accessibility Checker, Revert (Info); file.save /
file.saveAs / file.export by type (Save, Save As); a PDF with the Print
page's slides, layout (full page, notes, handouts 1-9), hidden slides and
frames, opened in the system viewer or downloaded on the web, with a
page preview (Print, Ctrl+P); Create PDF, Create Handouts and Change File
Type (Export); appearance and About (Account); Preferences (Options).
Share (cloud) is left out.

Close and Ctrl+W now ask Save / Don't Save / Cancel about unsaved
changes instead of closing at once. The Export dialog shares
SlideApp::export_as with the Export page. New UI commands: app.backstage
{page}, app.closePresentation, app.print; ui.inspect reports the page.
New icons drawn in code: back, save as, account, recent, new file, open
folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Docs: File tab (Backstage) in ui-parity, gaps, checklist and progress log (#76)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:56:02 -04:00

433 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File menu: new, open, save, export, close, properties.
use deckcraft_model::{Presentation, defaults};
use serde_json::{Value, json};
use super::*;
use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open),
cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes),
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: deckcraft|pptx}", has_doc, save),
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(
query "file.info",
"Info",
["File"],
None,
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
has_doc,
info
),
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
cmd!(
"file.properties",
"Properties",
["File"],
None,
"{title?, subject?, author?, keywords?, comments?, category?, company?} → properties",
has_doc,
properties
),
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
]
}
fn theme_named(name: &str) -> Option<deckcraft_model::Theme> {
deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name))
}
fn new(s: &mut Session, p: &Value) -> Result<Value> {
let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default();
let size = p
.get("size")
.and_then(Value::as_array)
.and_then(|a| Some(deckcraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)))
.filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0)
.unwrap_or(defaults::WIDE);
let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false));
let title = s.next_untitled();
let i = s.add_document(DocState::new(doc, None, title));
Ok(json!({"document": i}))
}
/// Recognise and read presentation bytes (native, PPTX).
pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
if deckcraft_format::sniff(bytes) {
return deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
}
if deckcraft_pptx::sniff(bytes) {
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
return Ok(p);
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".txt") || lower.ends_with(".md") {
let text = String::from_utf8_lossy(bytes);
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
deckcraft_format::outline_to_slides(&mut p, &text);
if p.slides.is_empty() {
p = defaults::new_presentation(None);
}
return Ok(p);
}
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
let missing_fonts = crate::fonts::missing_fonts(&p);
let mut st = DocState::new(p, path, title);
st.missing_fonts = missing_fonts;
s.add_document(st)
}
#[cfg(not(target_arch = "wasm32"))]
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = open_presentation(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
let i = add_opened(s, path, native.then(|| path.to_string()), doc);
let st = s.doc()?;
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts}))
}
#[cfg(target_arch = "wasm32")]
fn open(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("use file.openBytes in the browser".into()))
}
fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Presentation");
let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?;
let doc = open_presentation(name, &data)?;
let i = add_opened(s, name, None, doc);
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts}))
}
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `outline`).
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
match format {
"pptx" | "potx" | "ppsx" => deckcraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())),
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
o.slides = match p.get("slides") {
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
_ => None,
};
o.include_hidden = bool_or(p, "includeHidden", false);
o.text_layer = bool_or(p, "textLayer", true);
o.frame_slides = bool_or(p, "frame", true);
Ok(o)
}
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
None => (part, part),
};
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
let (lo, hi) = (a.min(b), a.max(b));
if lo == 0 || hi > count {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(invalid());
}
Ok(out)
}
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
}
pub fn format_for_path(path: &str) -> &'static str {
let l = path.to_ascii_lowercase();
if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") {
"pptx"
} else if l.ends_with(".png") {
"png"
} else if l.ends_with(".jpg") || l.ends_with(".jpeg") {
"jpeg"
} else if l.ends_with(".pdf") {
"pdf"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else {
"deckcraft"
}
}
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
let st = s.doc_mut()?;
st.saved_doc = st.doc.clone();
if path.is_some() {
st.path = path;
}
st.revision += 1;
let uid = st.uid;
if let Some(dir) = &s.recovery_dir {
crate::recovery::discard(dir, uid);
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
let tmp = format!("{path}.saving");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other("saving to a path is not available in the browser".into()))
}
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() });
return Ok(json!({"needsPath": true}));
};
let format = str_param(p, "format").unwrap_or(format_for_path(&path));
let bytes = save_bytes(&s.doc()?.doc, format)?;
write_file(&path, &bytes)?;
mark_saved(s, Some(path.clone()))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
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() });
return Ok(json!({"needsPath": true}));
}
save(s, p)
}
fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let format = str_param(p, "format").unwrap_or("deckcraft");
let bytes = save_bytes(&s.doc()?.doc, format)?;
Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()}))
}
/// Render slide `index` (current when absent) to PNG bytes.
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
let img =
deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() });
(img.to_png(), img.width, img.height)
}
fn render(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
if i >= st.doc.slides.len() {
return Err(bad("file.render", format!("no slide {i}")));
}
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
let st = s.doc()?;
let scale = f64_or(p, "scale", 2.0);
match format.as_str() {
"png" | "jpeg" | "jpg" => {
let slides: Vec<usize> = match usize_param(p, "slide") {
Some(i) => vec![i],
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
None => vec![st.selection.slide],
};
let mut written = vec![];
for (k, i) in slides.iter().enumerate() {
if *i >= st.doc.slides.len() {
return Err(bad("file.export", format!("no slide {i}")));
}
let img = deckcraft_render::render_slide(
&st.doc,
*i,
&deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() },
);
let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) };
let out = if slides.len() == 1 {
path.clone()
} else {
let pth = std::path::Path::new(&path);
let stem = pth.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Slide".into());
let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone());
pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string()
};
write_file(&out, &bytes)?;
written.push(out);
}
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = pdf_bytes(&st.doc, p)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
other => {
let bytes = save_bytes(&st.doc, other)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
}
}
/// File › Info: what the Info page shows about the active presentation.
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
"title": st.title(),
"path": st.path,
"format": st.path.as_deref().map(format_for_path),
"dirty": st.is_dirty(),
"slides": doc.slides.len(),
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
"sections": doc.sections.len(),
"bytes": bytes,
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
}))
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
if let Some(i) = s.active_index() {
s.close_document(i);
}
ok()
}
fn properties(s: &mut Session, p: &Value) -> Result<Value> {
let fields = ["title", "subject", "author", "keywords", "comments", "category", "company"];
if fields.iter().any(|f| p.get(*f).is_some()) {
s.edit(|doc, _| {
let pr = &mut doc.props;
for f in fields {
if let Some(v) = str_param(p, f) {
let slot = match f {
"title" => &mut pr.title,
"subject" => &mut pr.subject,
"author" => &mut pr.author,
"keywords" => &mut pr.keywords,
"comments" => &mut pr.comments,
"category" => &mut pr.category,
_ => &mut pr.company,
};
*slot = v.to_string();
}
}
Ok(())
})?;
}
Ok(serde_json::to_value(&s.doc()?.doc.props).unwrap_or_default())
}
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc_mut()?;
st.doc = st.saved_doc.clone();
st.history = crate::History { limit: st.history.limit, ..Default::default() };
st.revision += 1;
ok()
}
fn next_window(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.documents().len();
if n == 0 {
return ok();
}
let i = usize_param(p, "index").unwrap_or_else(|| (s.active_index().unwrap_or(0) + 1) % n);
s.set_active(i.min(n - 1));
ok()
}
fn recovery_dir(s: &Session) -> Result<std::path::PathBuf> {
s.recovery_dir.clone().ok_or_else(|| EngineError::Other("no recovery folder is set".into()))
}
fn recovery_save(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(json!({"written": crate::recovery::save(s, &dir)?}))
}
fn recovery_list(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(Value::Array(
crate::recovery::list(&dir)
.into_iter()
.map(|(uid, mut m)| {
if let Some(o) = m.as_object_mut() {
o.insert("uid".into(), json!(uid));
}
m
})
.collect(),
))
}
fn recovery_open(s: &mut Session, _p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
}
fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(s)?;
match p.get("uid").and_then(Value::as_u64) {
Some(uid) => crate::recovery::discard(&dir, uid),
None => {
for (uid, _) in crate::recovery::list(&dir) {
crate::recovery::discard(&dir, uid);
}
}
}
ok()
}