Merge PR #583 from NikitkaRa: PDF master header/footer text wraps and keeps mixed formatting (stacked on #580)

# Conflicts:
#	crates/render/src/lib.rs
#	crates/render/src/placed.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:30:43 -04:00
5 changed files with 82 additions and 31 deletions
+18 -27
View File
@@ -384,14 +384,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;
@@ -431,7 +431,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();
@@ -530,30 +539,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
@@ -178,3 +178,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
@@ -51,7 +51,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, PlacedShape, PlacedText, place_slide};
pub use placed::{Placed, PlacedLink, PlacedShape, 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
@@ -75,6 +75,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, None, false);
}
out.texts
}
/// `group` is the index in `out.shapes` of the enclosing group; `own` is true for the slide's own
/// shapes (recorded in `out.shapes`), false for the layout's and master's.
#[allow(clippy::too_many_arguments)]
+5 -3
View File
@@ -249,9 +249,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
@@ -386,6 +387,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-14 | minor | Size and Position dialog landed (position, size, rotation, scale, lock aspect, one undo). The dialog gap remains Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object and the rest of the ~60 |
| 2026-10-14 | minor | File › New has six original starter decks; a large template gallery is still open |
| 2026-10-13 | minor | Artistic effects: 7 of 22 (blur, pencil grayscale, line drawing, film grain, cutout, glow edges, photocopy) applied, removed and round-tripped (`a14:imgLayer`) |
| 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 |