//! PDF export. //! //! Each slide is drawn by the DeckCraft renderer at print resolution (so the PDF looks exactly like //! the slide, effects included) and overlaid with an invisible layer of real text from the text //! layout engine with embedded font subsets, so the text can be searched, selected and copied. //! Hyperlinks and shape actions become link annotations, slide titles become bookmarks. //! //! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3, //! 4, 6 or 9 slides per page). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::collections::HashMap; use craft_pdf::write::{self, Metadata, Writer, backend as krilla}; use deckcraft_fonts::FontDb; use deckcraft_model::Presentation; use deckcraft_model::text::Action as ClickAction; use deckcraft_render::{PlacedText, RenderOpts}; use krilla::action::{Action, LinkAction}; use krilla::annotation::{Annotation, LinkAnnotation, Target}; use krilla::destination::XyzDestination; use krilla::geom::{Point, Size, Transform}; use krilla::image::Image; use krilla::num::NormalizedF32; use krilla::outline::{Outline, OutlineNode}; use krilla::page::PageSettings; use krilla::paint::Fill; use krilla::surface::Surface; use krilla::text::{Font, GlyphId, KrillaGlyph, TextDirection}; use kurbo::{Affine, Rect}; use serde::{Deserialize, Serialize}; #[derive(Debug, thiserror::Error)] pub enum PdfError { #[error("nothing to export (no slides in range)")] NoPages, #[error("PDF writer: {0}")] Write(String), } impl From for PdfError { fn from(error: write::WriteError) -> Self { Self::Write(error.to_string()) } } pub type Result = std::result::Result; /// What each page shows. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum PageLayout { /// One slide per page at the slide size. #[default] Slides, /// Portrait page: the slide above its notes. Notes, /// Portrait page with `per_page` slides (1, 2, 3, 4, 6 or 9). Handouts { per_page: u8 }, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PdfOptions { pub layout: PageLayout, /// Raster resolution of slide artwork (dots per inch). pub dpi: f64, /// Slide indices to export (0-based); `None` = all. pub slides: Option>, pub include_hidden: bool, /// Invisible real-text layer (search, select, copy). pub text_layer: bool, /// Thin frame around each slide (notes/handouts). pub frame_slides: bool, /// Document title (defaults to the presentation title). pub title: Option, /// Handout/notes page size in points (default US Letter portrait; see [`paper_size`]). pub paper: (f64, f64), } impl Default for PdfOptions { fn default() -> Self { PdfOptions { layout: PageLayout::Slides, dpi: 200.0, slides: None, include_hidden: false, text_layer: true, frame_slides: true, title: None, paper: (612.0, 792.0), } } } /// Paper sizes the Print page offers, as (id, label, width × height in points, portrait). pub const PAPERS: [(&str, &str, (f64, f64)); 4] = [ ("letter", "Letter (8.5 × 11 in)", (612.0, 792.0)), ("a4", "A4 (210 × 297 mm)", (595.28, 841.89)), ("legal", "Legal (8.5 × 14 in)", (612.0, 1008.0)), ("a3", "A3 (297 × 420 mm)", (841.89, 1190.55)), ]; /// The page size in points for a paper id from [`PAPERS`] (case-insensitive), turned on its side /// when `landscape`. `None` for an unknown id. pub fn paper_size(id: &str, landscape: bool) -> Option<(f64, f64)> { let (_, _, (w, h)) = PAPERS.iter().find(|p| p.0.eq_ignore_ascii_case(id.trim()))?; Some(if landscape { (*h, *w) } else { (*w, *h) }) } /// The slide box on a notes page of `paper`: an inch of margin at the sides and the slide at most /// half the page tall, so landscape paper still leaves room for the notes. pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect { let (pw, ph) = paper; let mut w = (pw - 2.0 * 72.0).max(1.0); let mut h = w / aspect.max(0.01); let max_h = (ph * 0.5 - 54.0).max(1.0); if h > max_h { h = max_h; w = h * aspect; } let x = (pw - w) / 2.0; Rect::new(x, 54.0, x + w, 54.0 + h) } /// Export `pres` as PDF bytes. pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { write::guard(|| export_inner(pres, opts)) } fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let slides: Vec = match &opts.slides { Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(), None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(), }; if slides.is_empty() { return Err(PdfError::NoPages); } let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone()); let metadata = Metadata { creator: "DeckCraft".into(), producer: "DeckCraft".into(), title: (!title.trim().is_empty()).then_some(title), authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] }, ..Default::default() }; let mut writer = Writer::new(Default::default(), metadata); let pdf = writer.document_mut(); let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; // Page plan: per page, the slides on it with their boxes (page points). let pages: Vec> = match opts.layout { PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), PageLayout::Notes => { let b = notes_frames(pres, opts.paper).0; slides.iter().map(|&i| vec![(i, b)]).collect() } PageLayout::Handouts { per_page } => { let boxes = handout_boxes(per_page, opts.paper, sw / sh); slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect() } }; // Bookmarks and slide → page lookup for internal links. let page_of: HashMap = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect(); let mut outline = Outline::new(); for (p, v) in pages.iter().enumerate() { for (i, _) in v { let t = pres.slides[*i].title(); let t = t.lines().next().unwrap_or("").trim().to_string(); let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) }; outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0)))); } } pdf.set_outline(outline); for v in &pages { let (pw, ph) = match opts.layout { PageLayout::Slides => (sw, sh), _ => opts.paper, }; let size = Size::from_wh(pw as f32, ph as f32).ok_or(PdfError::NoPages)?; let mut page = pdf.start_page_with(PageSettings::new(size)); let mut annots = Vec::new(); { let mut s = page.surface(); for (i, b) in v { ex.slide(&mut s, *i, *b)?; annots.extend(ex.links(*i, *b, &page_of)); } match opts.layout { PageLayout::Notes => { if let Some((i, _)) = v.first() { let notes = pres.slides[*i].notes_text(); ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0)?; } ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?; } PageLayout::Handouts { per_page } => { if per_page == 3 { for (_, b) in v { ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1)); } } ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?; } PageLayout::Slides => {} } s.finish(); } for a in annots { page.add_annotation(a); } page.finish(); } writer.finish().map_err(Into::into) } /// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points. fn master_rect(pres: &Presentation, m: &deckcraft_model::Master, kind: deckcraft_model::PhType, paper: (f64, f64)) -> Option { let x = m.shapes.iter().find(|s| s.ph_type() == Some(kind))?.xfrm?; let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); let r = Rect::new(x.x * kx, x.y * ky, (x.x + x.w) * kx, (x.y + x.h) * ky); (r.width() > 1.0 && r.height() > 1.0).then_some(r) } /// A notes page's slide box and notes area on `paper`: where the notes master puts its slide image /// and body placeholders, or the default layout when it has none. pub fn notes_frames(pres: &Presentation, paper: (f64, f64)) -> (Rect, Rect) { let (pw, ph) = paper; let aspect = pres.slide_size.width.max(1.0) / pres.slide_size.height.max(1.0); let nm = pres.notes_master.as_deref(); let slide = match nm.and_then(|m| master_rect(pres, m, deckcraft_model::PhType::SlideImage, paper)) { // The slide keeps its own shape, centred in the frame. Some(f) => { let (w, h) = if f.width() / f.height() > aspect { (f.height() * aspect, f.height()) } else { (f.width(), f.width() / aspect) }; Rect::from_center_size(f.center(), (w, h)) } None => notes_slide_box(paper, aspect), }; let notes = nm .and_then(|m| master_rect(pres, m, deckcraft_model::PhType::Body, paper)) .unwrap_or_else(|| Rect::new(72.0, slide.y1 + 36.0, pw - 72.0, ph - 54.0)); (slide, notes) } /// 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 } } /// One laid-out piece of master text on a page of `paper` points: a run's text on one line, its /// baseline start and its size, colour, weight and slant. #[derive(Clone, Debug, PartialEq)] pub struct TextPiece { pub at: (f64, f64), pub text: String, pub size: f64, pub color: deckcraft_model::Rgba, pub bold: bool, pub italic: bool, } /// The header, date, footer and page number of `m` for page `n`, laid out by the text engine /// (wrapped, mixed formatting) and scaled to `paper`, for drawing without glyph access (the /// Notes Page view). pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, paper: (f64, f64), n: usize) -> Vec { let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0)); let mut out = vec![]; for pt in deckcraft_render::place_master_texts(pres, m, n) { for run in &pt.layout.runs { let Some(g) = run.glyphs.first() else { continue }; let text: String = pt .body .paragraphs .get(run.para) .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) .take(run.chars.1.saturating_sub(run.chars.0)) .collect(); if text.trim().is_empty() { continue; } let p = pt.transform * kurbo::Point::new(g.1, g.2); out.push(TextPiece { at: (p.x * kx, p.y * ky), text, size: run.size * ky, color: run.color, bold: run.fake_bold || run.face.weight >= 600.0, italic: run.fake_italic || run.face.italic, }); } } out } /// 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] { let Some(r) = master_rect(pres, m, kind, paper) else { continue }; let Some(sh) = m.shapes.iter().find(|s| s.ph_type() == Some(kind)) else { continue }; let text = if kind == PhType::SlideNum { n.to_string() } else { sh.text.as_ref().map(|t| t.text()).unwrap_or_default().trim().to_string() }; if text.is_empty() { continue; } let right = sh .text .as_ref() .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); 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 } /// Slide boxes for a handout page (portrait paper, half-inch-ish margins). pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec { let (pw, ph) = paper; let (mx, top, bottom) = (54.0, 54.0, 54.0); let (cols, rows, notes) = match per_page { 0 | 1 => (1, 1, false), 2 => (1, 2, false), 3 => (1, 3, true), 4 => (2, 2, false), 6 => (2, 3, false), _ => (3, 3, false), }; let gap = 18.0; let aw = if notes { (pw - 2.0 * mx) * 0.5 } else { pw - 2.0 * mx }; let ah = ph - top - bottom - 18.0; let cw = (aw - gap * (cols as f64 - 1.0)) / cols as f64; let ch = (ah - gap * (rows as f64 - 1.0)) / rows as f64; let (w, h) = if cw / ch > aspect { (ch * aspect, ch) } else { (cw, cw / aspect) }; let mut out = Vec::new(); for r in 0..rows { for c in 0..cols { let cx = mx + c as f64 * (cw + gap) + (cw - w) / 2.0; let cy = top + r as f64 * (ch + gap) + (ch - h) / 2.0; let cx = if notes { mx } else { cx }; out.push(Rect::new(cx, cy, cx + w, cy + h)); } } out } struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, fonts: HashMap, } fn tf(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) } fn grey(v: u8, opacity: f32) -> Fill { Fill { paint: krilla::color::rgb::Color::new(v, v, v).into(), opacity: NormalizedF32::new(opacity.clamp(0.0, 1.0)).unwrap_or(NormalizedF32::ONE), ..Default::default() } } /// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. fn straight(rgba: &mut [u8]) { for px in rgba.as_chunks_mut::<4>().0 { let a = px[3] as u32; if a != 0 && a != 255 { for c in &mut px[..3] { *c = (((*c as u32 * 255 + a / 2) / a).min(255)) as u8; } } } } /// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal /// backdrops become equal images, which the writer stores once. fn backdrop(mut img: deckcraft_render::Image) -> Image { for px in img.pixels.as_chunks_mut::<4>().0 { let a = px[3] as u32; for c in &mut px[..3] { *c = (*c as u32 + (255 - a)).min(255) as u8; } px[3] = 255; } Image::from_rgba8(img.pixels, img.width, img.height) } impl Exporter<'_> { fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result { if let Some(font) = self.fonts.get(&face.id()) { return Ok(font.clone()); } let font = write::font(face.data(), face.index(), None)?; self.fonts.insert(face.id(), font.clone()); Ok(font) } /// Slide `i` drawn into `b` (page points). fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> { let pres = self.pres; let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; // Two rasters per slide: the backdrop (background, master and layout graphics) is the same // on every slide that shares its layout, so the writer stores it once, and the slide's own // shapes draw over it (#845). let back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts); let front = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Content, &ropts); let (w, h) = (back.width, back.height); if w > 0 && h > 0 && let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); s.draw_image(backdrop(back), size); // Taken, not copied: a print-resolution slide is ~16 MB. let mut rgba = front.pixels; if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { straight(&mut rgba); s.draw_image(Image::from_rgba8(rgba, w, h), size); } s.pop(); } if self.opts.frame_slides && self.opts.layout != PageLayout::Slides { let p = kurbo::Shape::to_path(&b, 0.1); if let Some(path) = to_path(&p) { s.set_fill(None); s.set_stroke(Some(krilla::paint::Stroke { paint: krilla::color::rgb::Color::new(0x80, 0x80, 0x80).into(), width: 0.5, ..Default::default() })); s.draw_path(&path); s.set_stroke(None); } } 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, false)?; } let _ = sh; } Ok(()) } /// Invisible glyphs exactly over the rasterised text. fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> { for run in &pt.layout.runs { if run.glyphs.is_empty() || run.size <= 0.0 { continue; } let text: String = pt .body .paragraphs .get(run.para) .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) .take(run.chars.1.saturating_sub(run.chars.0)) .collect(); if text.trim().is_empty() { continue; } let font = self.font(&run.face)?; let n = run.glyphs.len(); let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n); let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2); let kg: Vec = run .glyphs .iter() .enumerate() .map(|(gi, g)| { let next = run.glyphs.get(gi + 1).map_or(g.1 + run.face.advance(g.0) * run.size / run.face.upem.max(1.0), |nx| nx.1); let range = ranges.get(gi).cloned().unwrap_or(text.len()..text.len()); KrillaGlyph::new(GlyphId::new(g.0), ((next - g.1) / run.size) as f32, 0.0, ((g.2 - y0) / run.size) as f32, 0.0, range, None) }) .collect(); s.push_transform(&tf(view * pt.transform * Affine::translate((x0, y0)))); 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(); } Ok(()) } fn links(&self, i: usize, b: Rect, page_of: &HashMap) -> Vec { let pres = self.pres; let k = b.width() / pres.slide_size.width.max(1.0); let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); let mut out = Vec::new(); for l in deckcraft_render::place_slide(pres, i).links { let r = view.transform_rect_bbox(l.rect).intersect(b); let Some(rect) = krilla::geom::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32) else { continue }; let goto = |slide: Option| { slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into())) }; let target = match &l.link.action { ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))), ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)), ClickAction::NextSlide => goto(Some(i + 1)), ClickAction::PreviousSlide => goto(i.checked_sub(1)), ClickAction::FirstSlide => goto(Some(0)), ClickAction::LastSlide => goto(pres.slides.len().checked_sub(1)), _ => None, }; if let Some(t) = target { let alt = if l.link.tooltip.is_empty() { None } else { Some(l.link.tooltip.clone()) }; out.push(Annotation::new_link(LinkAnnotation::new(rect, t), alt)); } } out } fn ui_font(&mut self) -> Result<(Font, std::sync::Arc)> { let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular"); let f = self.font(&face)?; Ok((f, face)) } /// Wrapped plain text (notes). fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> { let (font, face) = self.ui_font()?; let k = size / face.upem.max(1.0); let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::(); let lh = size * 1.3; let mut y = area.y0 + size; s.set_fill(Some(grey(0x20, 1.0))); 'outer: for para in text.split('\n') { let mut line = String::new(); for word in para.split(' ') { let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; if width(&cand) > area.width() && !line.is_empty() { if y > area.y1 { break 'outer; } s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); y += lh; line = word.to_string(); } else { line = cand; } } if y > area.y1 { break; } if !line.is_empty() { s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); } y += lh; } s.set_fill(None); Ok(()) } /// Ruled lines for handwritten notes (3-per-page handouts). fn note_lines(&self, s: &mut Surface, r: Rect) { let mut y = r.y0 + 18.0; s.set_stroke(Some(krilla::paint::Stroke { paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(), width: 0.5, ..Default::default() })); while y <= r.y1 { let mut p = kurbo::BezPath::new(); p.move_to((r.x0, y)); p.line_to((r.x1, y)); if let Some(path) = to_path(&p) { s.draw_path(&path); } y += 22.0; } s.set_stroke(None); } /// A notes or handout page's header, date, footer and page number from its master; just the /// 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) -> Result<()> { let Some(m) = master else { return self.footer(s, pw, ph, n) }; // 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)?; } Ok(()) } fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> { let (font, _) = self.ui_font()?; s.set_fill(Some(grey(0x60, 1.0))); s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto); s.set_fill(None); Ok(()) } } /// Byte range in `text` (the run's characters `chars`) of each of `n` glyphs, for the ToUnicode map. /// `cells` holds each glyph's shaping-cluster head (paragraph character index); a cluster runs to the /// next head in the run, so a ligature maps to all its characters and every glyph of a cluster shares /// its range (krilla then writes the text once). fn glyph_text_ranges(text: &str, chars: (usize, usize), cells: &[(usize, char)], n: usize) -> Vec> { let starts: Vec = text.char_indices().map(|(b, _)| b).collect(); let byte = |ci: usize| ci.checked_sub(chars.0).and_then(|i| starts.get(i).copied()).unwrap_or(text.len()); let mut heads: Vec = cells.iter().map(|c| c.0).collect(); heads.sort_unstable(); heads.dedup(); let end = |ci: usize| heads.get(heads.partition_point(|&h| h <= ci)).copied().unwrap_or(chars.1); (0..n).map(|gi| cells.get(gi).map_or(text.len()..text.len(), |&(ci, _)| byte(ci)..byte(end(ci)))).collect() } fn to_path(bp: &kurbo::BezPath) -> Option { use kurbo::PathEl; let mut pb = krilla::geom::PathBuilder::new(); for el in bp.elements() { match *el { PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32), PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32), PathEl::ClosePath => pb.close(), } } pb.finish() } #[cfg(test)] mod tests;