mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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+,"), "{}"),
|
||||
|
||||
Reference in New Issue
Block a user