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 |