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.
This commit is contained in:
Nikita Rakitin
2026-10-14 00:35:26 +03:00
parent 443fe300d0
commit 6fcf392370
4 changed files with 152 additions and 17 deletions
+78 -9
View File
@@ -112,10 +112,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_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<Vec<u8>> {
}
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, &notes, area, 12.0);
ex.paragraphs(&mut s, &notes, 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<Vec<u8>> {
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<Vec<u8>> {
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<Rect> {
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<Rect> {
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)));
+50
View File
@@ -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"));
}
}
+18 -4
View File
@@ -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));
+6 -4
View File
@@ -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 |