diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index c22dfb17..fca04d71 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 40a6afef..1af447ed 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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 = 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")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..74a3b8c9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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}; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0a0d4737..8acf72f2 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -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 { + 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; diff --git a/docs/gaps.md b/docs/gaps.md index 0ed1400e..e3a1091b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 |