From e6920f8ebe25e1dd65111a2a873c542bd88f3943 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:43:54 +0300 Subject: [PATCH] Print: choose paper size and orientation for notes and handouts The Print page always laid notes pages and handouts out on portrait US Letter. It now offers Letter, A4, Legal and A3 in portrait or landscape, as PowerPoint's Print settings do, and the preview shows the chosen page. file.export, app.print and the MCP export tool take the same `paper` and `orientation` params; unknown values are an error. A notes page keeps its slide within the top half of the page, so landscape paper still leaves room for the notes. Full-page slides still print on slide-shaped pages. Co-authored-by: Cursor --- crates/engine/src/cmd/file.rs | 17 +++++++-- crates/engine/src/tests.rs | 13 +++++++ crates/mcp/src/tests.rs | 8 +++-- crates/mcp/src/tools.rs | 4 ++- crates/pdf/src/lib.rs | 38 +++++++++++++++++--- crates/pdf/src/tests.rs | 33 ++++++++++++++++++ crates/ui-egui/src/backstage.rs | 50 +++++++++++++++++++++++++-- crates/ui-egui/src/backstage_tests.rs | 9 +++++ crates/ui-egui/src/lib.rs | 7 +++- 9 files changed, 165 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f6424f7c..3d3582c6 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", @@ -127,8 +127,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() }; @@ -146,6 +149,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 e83f72ef..091f2afd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,16 @@ fn slide_ranges_parse_like_powerpoints_print_box() { 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"); } + +/// 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 b1521521..a7a0d780 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -103,7 +103,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. @@ -112,7 +112,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 1a457dda..0e671ca6 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 f1d0fde3..a2d4653e 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", @@ -890,7 +895,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] = [ @@ -968,6 +990,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; } @@ -1000,8 +1044,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 115d782f..9bf2cd5e 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -435,4 +435,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 ec97e6e6..2812e919 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -157,7 +157,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+,"), "{}"),