From 32fcaec29309f740c046492fa8ccad29da26750d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:40:09 +0300 Subject: [PATCH] Preview the slide in Notes Master view; print master text in its own style Notes Master view draws the current slide inside the slide-image placeholder. Header, date, footer and page-number text on notes pages and handouts (PDF and the Notes Page view) uses the placeholder's resolved font, size, weight, slant and colour, scaled with the page. Stacked on #577. --- crates/pdf/src/lib.rs | 67 +++++++++++++++++++++++++++++------- crates/pdf/src/tests.rs | 31 ++++++++++++++++- crates/ui-egui/src/canvas.rs | 21 +++++++++++ crates/ui-egui/src/sorter.rs | 5 +-- docs/gaps.md | 8 +++-- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 5e9c6cb9..c22dfb17 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -206,10 +206,37 @@ pub fn notes_frames(pres: &Presentation, paper: (f64, f64)) -> (Rect, Rect) { (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)> { +/// How a master's header, date, footer or page number is printed: its own resolved formatting. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + pub family: String, + /// Points, before scaling to the paper. + pub size: f64, + pub bold: bool, + pub italic: bool, + pub color: deckcraft_model::Rgba, +} + +/// The resolved style of placeholder `sh`'s first run (or its paragraph's end properties) on +/// master `m`: run, list style, master text styles, theme fonts and colours. +pub fn master_text_style(pres: &Presentation, m: &deckcraft_model::Master, sh: &deckcraft_model::Shape) -> TextStyle { + use deckcraft_model::resolve::{self, Ctx}; + let ctx = Ctx::for_master(pres, m); + let para = sh.text.as_ref().and_then(|t| t.paragraphs.first()).cloned().unwrap_or_default(); + let own = para.runs.first().map(|r| r.props.clone()).unwrap_or_else(|| para.end_props.clone()); + let r = resolve::run(&ctx, sh, ¶, &own); + let family = r.font.as_deref().map(|f| if f.starts_with('+') { m.theme.font(f) } else { f.to_string() }).unwrap_or_default(); + let color = match &r.fill { + Some(deckcraft_model::Fill::Solid { color }) => ctx.color(color, None), + _ => deckcraft_model::Rgba::rgb(0x60, 0x60, 0x60), + }; + TextStyle { family, size: r.size.unwrap_or(12.0).clamp(1.0, 400.0), bold: r.bold == Some(true), italic: r.italic == Some(true), color } +} + +/// The header, date, footer and page-number text a master puts on page `n`, with where it goes, +/// whether it's right-aligned and its style. 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, TextStyle)> { use deckcraft_model::PhType; let mut out = vec![]; for kind in [PhType::Header, PhType::Date, PhType::Footer, PhType::SlideNum] { @@ -225,7 +252,10 @@ pub fn master_texts(pres: &Presentation, m: &deckcraft_model::Master, paper: (f6 .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)); + let mut style = master_text_style(pres, m, sh); + // Sizes scale with the page, as the placeholders do. + style.size *= paper.1 / pres.notes_size.height.max(1.0); + out.push((r, text, right, style)); } out } @@ -471,18 +501,31 @@ impl Exporter<'_> { /// 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) { + for (r, text, right, st) in master_texts(self.pres, m, (pw, ph), n) { + let style = match (st.bold, st.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + _ => "Regular", + }; + let family = if st.family.is_empty() { deckcraft_fonts::DEFAULT_FAMILY } else { st.family.as_str() }; + let face = FontDb::global().face(family, style); + let Some(font) = self.font(&face) else { continue }; + let size = st.size; + let k = size / face.upem.max(1.0); 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); + let c = st.color; + s.set_fill(Some(Fill { + paint: krilla::color::rgb::Color::new(c.r, c.g, c.b).into(), + opacity: NormalizedF32::new(f32::from(c.a) / 255.0).unwrap_or(NormalizedF32::ONE), + ..Default::default() + })); + s.draw_text(Point::from_xy(x as f32, y as f32), font, size as f32, line, false, TextDirection::Auto); + s.set_fill(None); } - 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 }; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8cdd074e..40a6afef 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -102,7 +102,7 @@ fn notes_pages_and_handouts_follow_their_masters() { 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(); + 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 }] { @@ -116,3 +116,32 @@ fn notes_pages_and_handouts_follow_their_masters() { assert!(bytes.starts_with(b"%PDF")); } } + +#[test] +fn master_text_keeps_its_own_formatting() { + use deckcraft_model::text::RunProps; + use deckcraft_model::{ColorRef, Fill, PhType, Rgba}; + let mut p = deck(1); + p.notes_size = deckcraft_model::Size::new(612.0, 792.0); + let mut header = ph(PhType::Header, 0.0, 0.0, 300.0, 30.0, "Board pack"); + if let Some(run) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()).and_then(|p| p.runs.first_mut()) { + run.props = RunProps { + size: Some(20.0), + bold: Some(true), + fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))), + font: Some("+mj-lt".into()), + ..Default::default() + }; + } + let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::Footer, 0.0, 760.0, 300.0, 30.0, "plain")], ..Default::default() }; + let texts = master_texts(&p, &m, (612.0, 792.0), 1); + let st = &texts[0].3; + assert_eq!((st.size, st.bold, st.italic, st.color), (20.0, true, false, Rgba::rgb(200, 0, 0))); + assert_eq!(st.family, m.theme.font("+mj-lt"), "theme font resolved"); + // Unstyled text falls back through the master's styles; sizes scale with the paper. + let half = master_texts(&p, &m, (306.0, 396.0), 1); + assert!((half[0].3.size - 10.0).abs() < 1e-9); + assert!(texts[1].3.size > 0.0 && !texts[1].3.bold); + p.notes_master = Some(Arc::new(m)); + assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF")); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0d5166e1..46453980 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,6 +139,27 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } app.perf.render_ms = app.textures.last_render_ms; media_frames(app, &painter, xf); + // Notes Master view: the current slide inside the slide-image placeholder, as in PowerPoint. + let notes_preview = app.session.active().filter(|d| d.selection.target == Target::NotesMaster).and_then(|d| { + let frame = d.doc.notes_master.as_ref()?.shapes.iter().find(|s| s.ph_type() == Some(deckcraft_model::PhType::SlideImage))?.xfrm?; + Some((d.doc.clone(), d.selection.slide.min(d.doc.slides.len().saturating_sub(1)), frame)) + }); + if let Some((doc, idx, f)) = notes_preview + && f.w > 1.0 + && f.h > 1.0 + { + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let (w, h) = if f.w / f.h > aspect { (f.h * aspect, f.h) } else { (f.w, f.w / aspect) }; + let (x, y) = (f.x + (f.w - w) / 2.0, f.y + (f.h - h) / 2.0); + let r = Rect::from_min_size( + pos2(slide_rect.min.x + x as f32 * scale, slide_rect.min.y + y as f32 * scale), + vec2(w as f32 * scale, h as f32 * scale), + ); + let mut budget = 2; + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) { + painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } if let Some(d) = app.session.active().filter(|d| d.selection.target == Target::Handout) { // Where the slides go on a printed handout, as in PowerPoint's preview. let (n, s) = (d.doc.notes_size, d.doc.slide_size); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 4b2b9e2c..2b426669 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -132,10 +132,11 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { 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) { + for (r, text, right, st) 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 c = Color32::from_rgba_unmultiplied(st.color.r, st.color.g, st.color.b, st.color.a); + ui.painter().text(at, align, text.lines().next().unwrap_or(""), theme::font((st.size as f32 * k).max(6.0)), c); } } let ppp = ui.ctx().pixels_per_point(); diff --git a/docs/gaps.md b/docs/gaps.md index 2504f4d2..0ed1400e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -164,9 +164,10 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Evidence:** View ▸ Handout Master and View ▸ Notes Master edit both masters (placeholders, 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. + and page number in their own fonts, sizes and colours; PDF handouts print the handout master's. + Notes Master view previews the slide in its slide-image placeholder. Left: master text is one + line per placeholder (no wrapping, no mixed formatting within it). +- **Estimate:** 1 h. ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 @@ -260,6 +261,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Notes Master slide preview; master header/footer text keeps its formatting | | 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 |