diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1b875ac2..37cb8e86 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,7 @@ 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] | \"1-3,5\", 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, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export), cmd!( query "file.info", "Info", @@ -144,8 +144,11 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { } } +/// Paper sizes for notes and handouts, for the UI's Print page. +pub use deckcraft_pdf::{PAPERS, paper_size}; + /// 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 +/// includeHidden, textLayer, frame, paper: letter|a4|legal|a3, orientation: portrait|landscape}` 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() }; @@ -163,6 +166,16 @@ pub fn pdf_options(p: &Value, count: usize) -> Result o.include_hidden = bool_or(p, "includeHidden", false); o.text_layer = bool_or(p, "textLayer", true); o.frame_slides = bool_or(p, "frame", true); + let landscape = match str_param(p, "orientation").unwrap_or("portrait") { + "portrait" => false, + "landscape" => true, + other => return Err(bad("file.export", format!("orientation “{other}” isn't portrait or landscape"))), + }; + let paper = str_param(p, "paper").unwrap_or("letter"); + o.paper = deckcraft_pdf::paper_size(paper, landscape).ok_or_else(|| { + let ids: Vec<&str> = deckcraft_pdf::PAPERS.iter().map(|p| p.0).collect(); + bad("file.export", format!("paper “{paper}” isn't one of {}", ids.join(", "))) + })?; Ok(o) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3f07bb4..a15cccab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1194,3 +1194,16 @@ fn font_add_refuses_non_fonts_and_rescan_reports_the_catalog() { let r = s.execute("font.rescan", &json!({})).unwrap(); assert!(r["families"].as_u64().unwrap() >= 1, "{r}"); } + +/// The Print page's paper and orientation reach the PDF options; Letter portrait by default. +#[test] +fn pdf_options_take_paper_and_orientation() { + let o = cmd::file::pdf_options(&json!({}), 1).unwrap(); + assert_eq!(o.paper, (612.0, 792.0)); + let o = cmd::file::pdf_options(&json!({"paper": "A4", "orientation": "landscape"}), 1).unwrap(); + assert_eq!(o.paper, (841.89, 595.28)); + let o = cmd::file::pdf_options(&json!({"paper": "legal"}), 1).unwrap(); + assert_eq!(o.paper, (612.0, 1008.0)); + assert!(cmd::file::pdf_options(&json!({"paper": "tabloid"}), 1).is_err()); + assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 70ba8107..db5ca5bf 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -186,7 +186,7 @@ fn export_tool_lists_pdf_and_its_options() { } assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"])); assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9])); - for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] { + for p in ["dpi", "slides", "includeHidden", "textLayer", "frame", "paper", "orientation"] { assert!(props.get(p).is_some(), "{p}: {export}"); } // What the schema describes is accepted. @@ -195,7 +195,11 @@ fn export_tool_lists_pdf_and_its_options() { tool(&mut s, "new_presentation", json!({})); for layout in ["slides", "notes", "handouts"] { let path = dir.join(format!("{layout}.pdf")); - tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3})); + tool( + &mut s, + "export", + json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3, "paper": "a4", "orientation": "landscape"}), + ); assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}"); } let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c193af5e..b5eab7f9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -129,7 +129,9 @@ pub fn tool_definitions() -> Vec { "slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"}, "includeHidden": boolean("PDF: include hidden slides (default false)"), "textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"), - "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)") + "frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"), + "paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"}, + "orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"} }), &["path"], ), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 97eb641b..65f42de2 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -67,7 +67,7 @@ pub struct PdfOptions { pub frame_slides: bool, /// Document title (defaults to the presentation title). pub title: Option, - /// Handout/notes page size in points (default US Letter portrait). + /// Handout/notes page size in points (default US Letter portrait; see [`paper_size`]). pub paper: (f64, f64), } @@ -86,6 +86,36 @@ impl Default for PdfOptions { } } +/// Paper sizes the Print page offers, as (id, label, width × height in points, portrait). +pub const PAPERS: [(&str, &str, (f64, f64)); 4] = [ + ("letter", "Letter (8.5 × 11 in)", (612.0, 792.0)), + ("a4", "A4 (210 × 297 mm)", (595.28, 841.89)), + ("legal", "Legal (8.5 × 14 in)", (612.0, 1008.0)), + ("a3", "A3 (297 × 420 mm)", (841.89, 1190.55)), +]; + +/// The page size in points for a paper id from [`PAPERS`] (case-insensitive), turned on its side +/// when `landscape`. `None` for an unknown id. +pub fn paper_size(id: &str, landscape: bool) -> Option<(f64, f64)> { + let (_, _, (w, h)) = PAPERS.iter().find(|p| p.0.eq_ignore_ascii_case(id.trim()))?; + Some(if landscape { (*h, *w) } else { (*w, *h) }) +} + +/// The slide box on a notes page of `paper`: an inch of margin at the sides and the slide at most +/// half the page tall, so landscape paper still leaves room for the notes. +pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect { + let (pw, ph) = paper; + let mut w = (pw - 2.0 * 72.0).max(1.0); + let mut h = w / aspect.max(0.01); + let max_h = (ph * 0.5 - 54.0).max(1.0); + if h > max_h { + h = max_h; + w = h * aspect; + } + let x = (pw - w) / 2.0; + Rect::new(x, 54.0, x + w, 54.0 + h) +} + /// Export `pres` as PDF bytes. pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { let slides: Vec = match &opts.slides { @@ -112,10 +142,8 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { let pages: Vec> = match opts.layout { PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), PageLayout::Notes => { - let (pw, _) = opts.paper; - let w = pw - 2.0 * 72.0; - let h = w * sh / sw; - slides.iter().map(|&i| vec![(i, Rect::new(72.0, 54.0, 72.0 + w, 54.0 + h))]).collect() + let b = notes_slide_box(opts.paper, sw / sh); + slides.iter().map(|&i| vec![(i, b)]).collect() } PageLayout::Handouts { per_page } => { let boxes = handout_boxes(per_page, opts.paper, sw / sh); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 305f1457..2c8cd628 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -66,3 +66,36 @@ fn handout_boxes_fit_the_paper() { } } } + +/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box +/// on the page and room left for the notes. +#[test] +fn notes_and_handouts_fit_any_paper_and_orientation() { + let p = deck(3); + for (id, _, _) in PAPERS { + for landscape in [false, true] { + let paper = paper_size(id, landscape).expect("known paper"); + assert_eq!(paper.0 > paper.1, landscape, "{id}"); + let b = notes_slide_box(paper, 16.0 / 9.0); + assert!(b.x0 >= 0.0 && b.x1 <= paper.0 && b.y1 <= paper.1 * 0.5, "{id} {landscape}: {b:?}"); + assert!((b.width() / b.height() - 16.0 / 9.0).abs() < 1e-6); + for n in [1, 3, 6, 9] { + for r in handout_boxes(n, paper, 16.0 / 9.0) { + assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= paper.0 && r.y1 <= paper.1, "{id} {landscape} {n}: {r:?}"); + } + } + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 6 }] { + let bytes = export(&p, &PdfOptions { paper, ..opts(layout) }).expect("export"); + let text = String::from_utf8_lossy(&bytes); + let media = format!("/MediaBox[0 0 {} {}]", fmt(paper.0), fmt(paper.1)); + assert!(text.contains(&media), "{id} {landscape} {layout:?}: no {media}"); + } + } + } + assert_eq!(paper_size("bogus", false), None); +} + +fn fmt(v: f64) -> String { + let s = format!("{:.2}", v as f32); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 13d36696..f91c8ed4 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -166,6 +166,9 @@ pub struct Backstage { pub print_layout: &'static str, pub print_hidden: bool, pub print_frame: bool, + /// Notes and handouts: a paper id from [`deckcraft_engine::cmd::file::PAPERS`] and its orientation. + pub print_paper: &'static str, + pub print_landscape: bool, pub preview_page: usize, pub export: ExportTask, /// Export › Change File Type / Save As: the chosen type (an [`SAVE_TYPES`] id). @@ -187,6 +190,8 @@ impl Backstage { print_layout: "slides", print_hidden: false, print_frame: true, + print_paper: "letter", + print_landscape: false, preview_page: 0, export: ExportTask::Pdf, file_type: "pptx", @@ -911,7 +916,24 @@ pub fn print_params(bs: &Backstage, slides: &[usize]) -> Value { _ => ("slides", 0), }; // The list is already filtered for hidden slides, so the PDF takes it as it is. - json!({"layout": layout, "perPage": per, "slides": slides, "includeHidden": true, "frame": bs.print_frame}) + json!({ + "layout": layout, + "perPage": per, + "slides": slides, + "includeHidden": true, + "frame": bs.print_frame, + "paper": bs.print_paper, + "orientation": if bs.print_landscape { "landscape" } else { "portrait" }, + }) +} + +/// The printed page size in points: the slide size for full-page slides, else the chosen paper. +pub fn print_page_size(bs: &Backstage, doc: &deckcraft_model::Presentation) -> (f32, f32) { + if bs.print_layout == "slides" { + return (doc.slide_size.width as f32, doc.slide_size.height as f32); + } + let (w, h) = deckcraft_engine::cmd::file::paper_size(bs.print_paper, bs.print_landscape).unwrap_or((612.0, 792.0)); + (w as f32, h as f32) } const LAYOUTS: [(&str, &str); 8] = [ @@ -989,6 +1011,28 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } }); ui.add_space(4.0); + // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are + // for notes pages and handouts. + ui.add_enabled_ui(bs.print_layout != "slides", |ui| { + let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); + egui::ComboBox::from_id_salt("backstage_print_paper").width(240.0).selected_text(paper).show_ui(ui, |ui| { + for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { + if ui.selectable_label(bs.print_paper == id, label).clicked() { + bs.print_paper = id; + } + } + }); + ui.add_space(4.0); + let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; + egui::ComboBox::from_id_salt("backstage_print_orientation").width(240.0).selected_text(orient).show_ui(ui, |ui| { + for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + if ui.selectable_label(bs.print_landscape == land, label).clicked() { + bs.print_landscape = land; + } + } + }); + }); + ui.add_space(4.0); if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { bs.preview_page = 0; } @@ -1021,8 +1065,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ let last = pages.len().saturating_sub(1); bs.preview_page = bs.preview_page.min(last); let Some(on_page) = pages.get(bs.preview_page).copied() else { return }; - // Full-page slides print on slide-shaped pages; notes and handouts on portrait Letter. - let (pw, ph) = if bs.print_layout == "slides" { (doc.slide_size.width as f32, doc.slide_size.height as f32) } else { (612.0, 792.0) }; + // Full-page slides print on slide-shaped pages; notes and handouts on the chosen paper. + let (pw, ph) = print_page_size(bs, doc); let avail_w = ui.available_width().clamp(200.0, 760.0); let scale = (avail_w / pw.max(1.0)).min(520.0 / ph.max(1.0)); let (area, _) = ui.allocate_exact_size(vec2(avail_w, ph * scale + 24.0), Sense::hover()); diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 36da4442..7a808841 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -492,4 +492,13 @@ fn print_slides_follow_the_settings() { bs.print_layout = "h6"; assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6); assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts"); + assert_eq!(print_params(&bs, &[1])["paper"], "letter"); + assert_eq!(print_page_size(&bs, &doc), (612.0, 792.0)); + bs.print_paper = "a4"; + bs.print_landscape = true; + assert_eq!(print_params(&bs, &[1])["orientation"], "landscape"); + let (w, h) = print_page_size(&bs, &doc); + assert!((w - 841.89).abs() < 0.01 && (h - 595.28).abs() < 0.01); + bs.print_layout = "slides"; + assert_eq!(print_page_size(&bs, &doc), (doc.slide_size.width as f32, doc.slide_size.height as f32), "slides print slide-shaped"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cecb9650..170f1230 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -169,7 +169,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.home", "Home", None, "{on?}"), ("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"), ("app.closePresentation", "Close", Some("Cmd+W"), "{}"), - ("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"), + ( + "app.print", + "Print", + Some("Cmd+P"), + "{layout?, perPage?, slides?, includeHidden?, frame?, paper?: letter|a4|legal|a3, orientation?: portrait|landscape}", + ), ("app.about", "About DeckCraft", None, "{}"), ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),