mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
+55
-12
@@ -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 };
|
||||
|
||||
+30
-1
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user