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>
This commit is contained in:
Brandon Thomas 2026-10-12 23:44:53 -04:00
parent f6c143ac85
commit c24ef3cd64
2 changed files with 133 additions and 9 deletions

View File

@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
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], includeHidden?, textLayer?, frame?}", has_doc, export),
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!(
@ -118,26 +127,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
}
}
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
/// includeHidden, textLayer, frame}`.
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
/// 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).min(9) as u8 },
"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 = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
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);
o
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)).map_err(|e| EngineError::Other(e.to_string()))
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 {
@ -264,7 +306,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": written}))
}
"pdf" => {
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = pdf_bytes(&st.doc, p)?;
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@ -276,6 +318,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// 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);

View File

@ -774,3 +774,64 @@ fn ime_composition_underlines_follow_the_text() {
s.commit_text("日本語").unwrap();
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
}
/// File › Info (#76): the active presentation's name, location, counts and properties.
#[test]
fn file_info_reports_the_presentation() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("slide.go", &json!({"index": 1})).unwrap();
s.execute("slide.hide", &json!({})).unwrap();
s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["slides"], 3);
assert_eq!(v["hiddenSlides"], 1);
assert_eq!(v["path"], serde_json::Value::Null);
assert_eq!(v["dirty"], true);
assert_eq!(v["properties"]["title"], "Quarterly");
assert_eq!(v["properties"]["author"], "Ada");
assert!(v["title"].as_str().is_some_and(|t| !t.is_empty()));
// A query: no undo step, and nothing changes.
let before = s.doc().unwrap().history.undo.len();
s.execute("file.info", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), before);
// Saved to disk, it reports where and how big.
let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Info test.pptx").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
let v = s.execute("file.info", &json!({})).unwrap();
assert_eq!(v["path"], path.as_str());
assert_eq!(v["format"], "pptx");
assert_eq!(v["dirty"], false);
assert!(v["bytes"].as_u64().is_some_and(|b| b > 0));
let _ = std::fs::remove_dir_all(&dir);
// Nothing open: an error, not a panic.
let mut empty = Session::new();
assert!(empty.execute("file.info", &json!({})).is_err());
}
/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based.
#[test]
fn slide_ranges_parse_like_powerpoints_print_box() {
use cmd::file::parse_slide_range as r;
assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]);
assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats");
assert_eq!(r(" 6 ", 6).unwrap(), vec![5]);
assert!(r("7", 6).is_err(), "past the last slide");
assert!(r("0", 6).is_err());
assert!(r("a-b", 6).is_err());
assert!(r(" , ", 6).is_err());
assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused");
// file.export takes the same text for a PDF.
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap();
let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap();
assert!(one.len() < both.len());
assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err());
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}