Lay out master header/footer text with the text engine in PDF pages

Notes pages and handouts lay out the master's header, date, footer and
page-number placeholders with deckcraft-text (render::place_master_texts),
so their text wraps in the placeholder and keeps mixed run formatting,
drawn as visible glyphs. Stacked on #580.
This commit is contained in:
Nikita Rakitin
2026-10-14 00:44:09 +03:00
parent 32fcaec293
commit 11e63e31e1
5 changed files with 82 additions and 31 deletions
+18 -27
View File
@@ -355,14 +355,14 @@ impl Exporter<'_> {
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view);
self.text_layer(s, &pt, view, false);
}
let _ = sh;
}
}
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine) {
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
@@ -402,7 +402,16 @@ impl Exporter<'_> {
})
.collect();
s.push_transform(&tf(view * pt.transform * Affine::translate((x0, y0))));
s.set_fill(Some(grey(0, 0.0)));
s.set_fill(Some(if visible {
let c = run.color;
Fill {
paint: krilla::color::rgb::Color::new(c.r, c.g, c.b).into(),
opacity: NormalizedF32::new((f64::from(c.a) / 255.0 * run.alpha) as f32).unwrap_or(NormalizedF32::ONE),
..Default::default()
}
} else {
grey(0, 0.0)
}));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, run.size as f32, false);
s.set_fill(None);
s.pop();
@@ -501,30 +510,12 @@ 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) };
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;
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);
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
// wrapping, alignment and mixed formatting as the master has them.
let pres = self.pres;
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
for pt in deckcraft_render::place_master_texts(pres, m, n) {
self.text_layer(s, &pt, view, true);
}
}
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
+34
View File
@@ -145,3 +145,37 @@ fn master_text_keeps_its_own_formatting() {
p.notes_master = Some(Arc::new(m));
assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF"));
}
#[test]
fn master_text_wraps_and_keeps_mixed_formatting() {
use deckcraft_model::text::{Run, RunProps};
use deckcraft_model::{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, 200.0, 80.0, "");
if let Some(para) = header.text.as_mut().and_then(|t| t.paragraphs.first_mut()) {
para.runs = vec![
Run::with("Quarterly board pack ", RunProps { size: Some(18.0), bold: Some(true), ..Default::default() }),
Run::with(
"for internal circulation only, please do not forward",
RunProps {
size: Some(9.0),
fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(200, 0, 0)))),
..Default::default()
},
),
];
}
let m = deckcraft_model::Master { shapes: vec![header, ph(PhType::SlideNum, 312.0, 760.0, 300.0, 30.0, "")], ..Default::default() };
let placed = deckcraft_render::place_master_texts(&p, &m, 4);
assert_eq!(placed.len(), 2, "header and page number");
let h = &placed[0].layout;
assert!(h.lines.len() >= 2, "wraps inside its 200 pt placeholder: {} lines", h.lines.len());
let sizes: Vec<f64> = h.runs.iter().filter(|r| !r.glyphs.is_empty()).map(|r| r.size).collect();
assert!(sizes.iter().any(|s| (*s - 18.0).abs() < 0.5) && sizes.iter().any(|s| (*s - 9.0).abs() < 0.5), "{sizes:?}");
assert!(h.runs.iter().any(|r| r.color == Rgba::rgb(200, 0, 0)));
let num: String = placed[1].body.text();
assert_eq!(num, "4");
p.notes_master = Some(Arc::new(m));
assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF"));
}
+1 -1
View File
@@ -49,7 +49,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
pub use placed::{Placed, PlacedLink, PlacedText, place_master_texts, place_slide};
pub use table::row_heights as table_row_heights;
pub use transition::{SlideParts, composite_transition, render_transition};
+24
View File
@@ -55,6 +55,30 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
out
}
/// The header, date, footer and page-number text of a notes or handout `master`, laid out in its
/// placeholders (master points) with their own formatting and wrapping, for page `n`. The page
/// number placeholder shows `n` in its first run's formatting.
pub fn place_master_texts(pres: &Presentation, master: &deckcraft_model::Master, n: usize) -> Vec<PlacedText> {
use deckcraft_model::PhType;
let mut out = Placed::default();
let ctx = Ctx::for_master(pres, master);
let fields = SlideFields::new(u32::try_from(n).unwrap_or(u32::MAX), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
for s in master.shapes.iter().filter(|s| matches!(s.ph_type(), Some(PhType::Header | PhType::Date | PhType::Footer | PhType::SlideNum))) {
let mut s = s.clone();
if s.ph_type() == Some(PhType::SlideNum) {
let mut body = s.text.clone().unwrap_or_default();
let props =
body.paragraphs.first().and_then(|p| p.runs.first().map(|r| r.props.clone()).or(Some(p.end_props.clone()))).unwrap_or_default();
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
para.runs = vec![deckcraft_model::text::Run::with(n.to_string(), props)];
body.paragraphs = vec![para];
s.text = Some(body);
}
place(&mut out, &ctx, &fields, &s, Affine::IDENTITY, 0);
}
out.texts
}
fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || s.hidden {
return;
+5 -3
View File
@@ -165,9 +165,10 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
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 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.
Notes Master view previews the slide in its slide-image placeholder. PDF pages lay master text
out with the text engine (wrapping, mixed formatting). Left: the on-screen Notes Page view still
draws it as one line per placeholder.
- **Estimate:** 0.5 h.
### Edit points, text warp, 3-D
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
@@ -261,6 +262,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | PDF master header/footer text wraps and keeps mixed formatting |
| 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 |