Merge PR #499 from NikitkaRa: Print: choose paper size and orientation for notes and handouts

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:46 -04:00
9 changed files with 165 additions and 14 deletions
+15 -2
View File
@@ -15,7 +15,7 @@ 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] | \"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<Vec<u8>> {
}
}
/// 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<deckcraft_pdf::PdfOptions> {
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<deckcraft_pdf::PdfOptions>
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)
}
+13
View File
@@ -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());
}
+6 -2
View File
@@ -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);
+3 -1
View File
@@ -129,7 +129,9 @@ pub fn tool_definitions() -> Vec<Value> {
"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"],
),
+33 -5
View File
@@ -67,7 +67,7 @@ pub struct PdfOptions {
pub frame_slides: bool,
/// Document title (defaults to the presentation title).
pub title: Option<String>,
/// 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<Vec<u8>> {
let slides: Vec<usize> = match &opts.slides {
@@ -112,10 +142,8 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let pages: Vec<Vec<(usize, Rect)>> = 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);
+33
View File
@@ -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()
}
+47 -3
View File
@@ -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());
+9
View File
@@ -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");
}
+6 -1
View File
@@ -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+,"), "{}"),