Files
deckcraft/crates/pdf/src/lib.rs
T

698 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<write::WriteError> for PdfError {
fn from(error: write::WriteError) -> Self {
Self::Write(error.to_string())
}
}
pub type Result<T> = std::result::Result<T, PdfError>;
/// 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), lowered for a slide whose raster would pass 64M pixels.
pub dpi: f64,
/// Slide indices to export (0-based); `None` = all.
pub slides: Option<Vec<usize>>,
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<String>,
/// 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<Vec<u8>> {
write::guard(|| export_inner(pres, opts))
}
fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec<Vec<(usize, Rect)>> {
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()
}
}
}
fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline {
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))));
}
}
outline
}
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let slides: Vec<usize> = 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 = plan_pages(pres, opts, &slides, sw, sh);
// Bookmarks and slide → page lookup for internal links.
let page_of: HashMap<usize, usize> = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect();
pdf.set_outline(build_outline(pres, &pages));
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, &notes, 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<Rect> {
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, &para, &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<TextPiece> {
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::<String>())
.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<Rect> {
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<u32, Font>,
}
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)
}
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
let pixels = slide.0 * scale * slide.1 * scale;
if pixels > MAX_RASTER_PIXELS { scale * (MAX_RASTER_PIXELS / pixels).sqrt() } else { scale }
}
impl Exporter<'_> {
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
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 = raster_scale(b.width(), (sw, sh), self.opts.dpi);
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::<String>())
.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<KrillaGlyph> = 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<usize, usize>) -> Vec<Annotation> {
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<usize>| {
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<deckcraft_fonts::FontFace>)> {
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::<f64>();
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<std::ops::Range<usize>> {
let starts: Vec<usize> = 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<usize> = 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<krilla::geom::Path> {
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;