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