Files
deckcraft/crates/engine/src/cmd/file.rs
T
Brandon ThomasandClaude Opus 5.5 c24ef3cd64 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>
2026-10-12 23:52:09 -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()
}