From 6fcf3923704ec75dc980b1f466a64ce7cb55aed8 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:35:26 +0300 Subject: [PATCH] Lay out notes pages and handouts from their masters PDF notes pages and the Notes Page view put the slide (keeping its aspect) and the notes where the notes master's slide-image and body placeholders are, scaled from the notes size to the paper, and print the master's header, date, footer and page number. PDF handouts print the handout master's. Decks without these masters keep the old layout. Stacked on #573. --- crates/pdf/src/lib.rs | 87 ++++++++++++++++++++++++++++++++---- crates/pdf/src/tests.rs | 50 +++++++++++++++++++++ crates/ui-egui/src/sorter.rs | 22 +++++++-- docs/gaps.md | 10 +++-- 4 files changed, 152 insertions(+), 17 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 97eb641b..5e9c6cb9 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -112,10 +112,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_frames(pres, opts.paper).0; + slides.iter().map(|&i| vec![(i, b)]).collect() } PageLayout::Handouts { per_page } => { let boxes = handout_boxes(per_page, opts.paper, sw / sh); @@ -151,12 +149,11 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { } match opts.layout { PageLayout::Notes => { - if let Some((i, b)) = v.first() { + if let Some((i, _)) = v.first() { let notes = pres.slides[*i].notes_text(); - let area = Rect::new(72.0, b.y1 + 36.0, pw - 72.0, ph - 54.0); - ex.paragraphs(&mut s, ¬es, area, 12.0); + ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0); } - ex.footer(&mut s, pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1)); + ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1)); } PageLayout::Handouts { per_page } => { if per_page == 3 { @@ -164,7 +161,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1)); } } - ex.footer(&mut s, pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1)); + ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1)); } PageLayout::Slides => {} } @@ -178,6 +175,61 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) } +/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points. +fn master_rect(pres: &Presentation, m: &deckcraft_model::Master, kind: deckcraft_model::PhType, paper: (f64, f64)) -> Option { + let x = m.shapes.iter().find(|s| s.ph_type() == Some(kind))?.xfrm?; + let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); + let r = Rect::new(x.x * kx, x.y * ky, (x.x + x.w) * kx, (x.y + x.h) * ky); + (r.width() > 1.0 && r.height() > 1.0).then_some(r) +} + +/// A notes page's slide box and notes area on `paper`: where the notes master puts its slide image +/// and body placeholders, or the default layout when it has none. +pub fn notes_frames(pres: &Presentation, paper: (f64, f64)) -> (Rect, Rect) { + let (pw, ph) = paper; + let aspect = pres.slide_size.width.max(1.0) / pres.slide_size.height.max(1.0); + let nm = pres.notes_master.as_deref(); + let slide = match nm.and_then(|m| master_rect(pres, m, deckcraft_model::PhType::SlideImage, paper)) { + // The slide keeps its own shape, centred in the frame. + Some(f) => { + let (w, h) = if f.width() / f.height() > aspect { (f.height() * aspect, f.height()) } else { (f.width(), f.width() / aspect) }; + Rect::from_center_size(f.center(), (w, h)) + } + None => { + let w = pw - 2.0 * 72.0; + Rect::new(72.0, 54.0, 72.0 + w, 54.0 + w / aspect) + } + }; + let notes = nm + .and_then(|m| master_rect(pres, m, deckcraft_model::PhType::Body, paper)) + .unwrap_or_else(|| Rect::new(72.0, slide.y1 + 36.0, pw - 72.0, ph - 54.0)); + (slide, notes) +} + +/// The header, date, footer and page-number text a master puts on page `n`, with where it goes +/// and whether it's right-aligned. Placeholders with no text are left out; the page number is +/// always `n`. +pub fn master_texts(pres: &Presentation, m: &deckcraft_model::Master, paper: (f64, f64), n: usize) -> Vec<(Rect, String, bool)> { + use deckcraft_model::PhType; + let mut out = vec![]; + for kind in [PhType::Header, PhType::Date, PhType::Footer, PhType::SlideNum] { + let Some(r) = master_rect(pres, m, kind, paper) else { continue }; + let Some(sh) = m.shapes.iter().find(|s| s.ph_type() == Some(kind)) else { continue }; + let text = if kind == PhType::SlideNum { n.to_string() } else { sh.text.as_ref().map(|t| t.text()).unwrap_or_default().trim().to_string() }; + if text.is_empty() { + continue; + } + let right = sh + .text + .as_ref() + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.props.align) + .map_or(matches!(kind, PhType::Date | PhType::SlideNum), |a| a == deckcraft_model::text::Align::Right); + out.push((r, text, right)); + } + out +} + /// Slide boxes for a handout page (portrait paper, half-inch-ish margins). pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec { let (pw, ph) = paper; @@ -415,6 +467,23 @@ impl Exporter<'_> { s.set_stroke(None); } + /// A notes or handout page's header, date, footer and page number from its master; just the + /// page number when the deck has no such master. + fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) { + let Some(m) = master else { return self.footer(s, pw, ph, n) }; + let Some((font, face)) = self.ui_font() else { return }; + let size = 9.0; + let k = size / face.upem.max(1.0); + s.set_fill(Some(grey(0x60, 1.0))); + for (r, text, right) in master_texts(self.pres, m, (pw, ph), n) { + let line = text.lines().next().unwrap_or(""); + let w: f64 = line.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum(); + let x = if right { r.x1 - w } else { r.x0 }; + let y = r.y0 + (r.height() + size * 0.7) / 2.0; + s.draw_text(Point::from_xy(x as f32, y as f32), font.clone(), size as f32, line, false, TextDirection::Auto); + } + s.set_fill(None); + } fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) { let Some((font, _)) = self.ui_font() else { return }; s.set_fill(Some(grey(0x60, 1.0))); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 305f1457..8cdd074e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -66,3 +66,53 @@ fn handout_boxes_fit_the_paper() { } } } + +fn ph(kind: deckcraft_model::PhType, x: f64, y: f64, w: f64, h: f64, text: &str) -> deckcraft_model::Shape { + deckcraft_model::Shape { + xfrm: Some(deckcraft_model::Xfrm::new(x, y, w, h)), + ph: Some(deckcraft_model::Placeholder { kind, ..Default::default() }), + text: Some(TextBody::from_text(text)), + ..Default::default() + } +} + +#[test] +fn notes_pages_and_handouts_follow_their_masters() { + use deckcraft_model::PhType; + let mut p = deck(2); + let paper = (612.0, 792.0); + // No masters: the default layout (slide at the top, one-inch side margins). + let (slide, notes) = notes_frames(&p, paper); + assert_eq!((slide.x0, slide.y0), (72.0, 54.0)); + assert!(notes.y0 > slide.y1); + // A notes master moves the slide and the notes, scaled from the notes size to the paper. + p.notes_size = deckcraft_model::Size::new(612.0, 792.0); + p.notes_master = Some(Arc::new(deckcraft_model::Master { + shapes: vec![ + ph(PhType::SlideImage, 100.0, 400.0, 412.0, 300.0, ""), + ph(PhType::Body, 50.0, 50.0, 500.0, 300.0, ""), + ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Q3 review"), + ph(PhType::Date, 312.0, 0.0, 300.0, 30.0, ""), + ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "<#>"), + ], + ..Default::default() + })); + let (slide, notes) = notes_frames(&p, paper); + assert!(slide.y0 >= 400.0 && slide.y1 <= 700.0 + 1e-6 && slide.x0 >= 100.0 - 1e-6, "{slide:?}"); + assert!(((slide.width() / slide.height()) - p.slide_size.width / p.slide_size.height).abs() < 1e-6); + assert_eq!(notes, Rect::new(50.0, 50.0, 550.0, 350.0)); + let texts = master_texts(&p, p.notes_master.as_deref().unwrap(), paper, 7); + let got: Vec<(&str, bool)> = texts.iter().map(|(_, t, r)| (t.as_str(), *r)).collect(); + assert_eq!(got, [("Q3 review", false), ("7", true)], "empty date left out, number is the page"); + // Notes pages and handouts export with the masters. + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] { + if layout != PageLayout::Notes { + p.handout_master = Some(Arc::new(deckcraft_model::Master { + shapes: vec![ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "Confidential")], + ..Default::default() + })); + } + let bytes = export(&p, &PdfOptions { layout, ..Default::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + } +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..4b2b9e2c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -121,16 +121,30 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let page = Rect::from_center_size(avail.center(), vec2(page_w, page_h)); ui.painter().rect_filled(page.translate(vec2(2.0, 2.0)), CornerRadius::ZERO, t.shadow); ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); - let sw = page_w * 0.78; - let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; - let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)); + // The notes master places the slide, the notes and the header and footer text (in Letter + // points, as the PDF notes pages do). + let paper = (612.0, 792.0); + let k = page_w / paper.0 as f32; + let to_screen = |(x0, y0, x1, y1): (f64, f64, f64, f64)| { + Rect::from_min_max(page.min + vec2(x0 as f32 * k, y0 as f32 * k), page.min + vec2(x1 as f32 * k, y1 as f32 * k)) + }; + let (slide_box, notes_box) = deckcraft_engine::pdf::notes_frames(&doc, paper); + let srect = to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1)); + let sw = srect.width(); + if let Some(m) = doc.notes_master.as_deref() { + for (r, text, right) in deckcraft_engine::pdf::master_texts(&doc, m, paper, idx + 1) { + let r = to_screen((r.x0, r.y0, r.x1, r.y1)); + let (at, align) = if right { (r.right_center(), egui::Align2::RIGHT_CENTER) } else { (r.left_center(), egui::Align2::LEFT_CENTER) }; + ui.painter().text(at, align, text.lines().next().unwrap_or(""), theme::font(9.0 * k.max(0.8)), Color32::from_gray(96)); + } + } let ppp = ui.ctx().pixels_per_point(); let mut budget = 2; if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) { ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); - let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); + let notes_rect = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); let id = egui::Id::new(("notespage", idx)); diff --git a/docs/gaps.md b/docs/gaps.md index f9a216ac..2504f4d2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -162,10 +162,11 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Notes and Handout masters - **Evidence:** View ▸ Handout Master and View ▸ Notes Master edit both masters (placeholders, - background, a slides-per-page preview for handouts) and PPTX keeps them. The notes master's slide - image is an empty frame rather than a slide preview, and notes pages and PDF handouts don't take - their layout and text from the masters yet. -- **Estimate:** 2–3 h (notes pages and PDF handouts follow the masters). + background, a slides-per-page preview for handouts) and PPTX keeps them. Notes pages (view and + PDF) place the slide and notes where the notes master does and print its header, date, footer + and page number; PDF handouts print the handout master's. Left: the master's slide image is an + empty frame rather than a slide preview, and master text prints in one plain style. +- **Estimate:** 1–2 h. ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 @@ -259,6 +260,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Notes pages and PDF handouts follow the notes and handout masters | | 2026-10-14 | minor | Notes Master view: the masters gap is now notes pages and PDF handouts following the masters | | 2026-10-14 | minor | Handout Master view: the masters gap is now the Notes Master view and handout text in PDF | | 2026-10-13 | minor | Owned PPTX corpus for tables, charts, animations, media, sections and masters. Handout-master text is kept; the corpus gap is now a larger set and PowerPoint reference renders |