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.
This commit is contained in:
Nikita Rakitin
2026-10-14 00:40:09 +03:00
parent 6fcf392370
commit 32fcaec293
5 changed files with 114 additions and 18 deletions
+55 -12
View File
@@ -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, &para, &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 };
+30 -1
View File
@@ -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"));
}
+21
View File
@@ -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);
+3 -2
View File
@@ -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();
+5 -3
View File
@@ -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 |