diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index cab5805..f6424f7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,16 @@ pub fn specs() -> Vec { 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> { } } -/// 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 { 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> { + 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 = 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::().map_err(|_| invalid())?, b.parse::().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> { - 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 { 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 { } } +/// File › Info: what the Info page shows about the active presentation. +fn info(s: &mut Session, _p: &Value) -> Result { + 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 = 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 { if let Some(i) = s.active_index() { s.close_document(i); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 673f8ec..e83f72e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +}