//! The Slides PDF module: SVG pages in, one PDF out, compiled to wasm and //! run under wazero beside the CRDT core (`core/`). It wraps `krilla` (a //! PDF writer) and `krilla-svg` (its SVG converter, over `usvg`) with a //! `fontdb` that holds only the faces the host hands over — the Liberation //! TTFs the layout engine measured with — so the PDF's text is real glyphs //! from those exact fonts, subset and embedded, never paths and never a //! system font. //! //! Until 2026-09-09 this was `svg2pdf`, whose repository (typst/svg2pdf) //! was archived on 2026-07-10 naming krilla as its successor. The two are //! by the same author over the same usvg tree, so the swap kept every //! property the module had and improved two: a font subset is written //! once per document rather than once per page (200 pages went from 1.72 //! MB to 140 KB) and the render is about three times faster. The module //! grew from 3.50 to 5.06 MiB. See spec §1 ruling 13. //! //! Each SVG becomes one page whose size is the SVG's own `width`/`height` //! in user units read as PDF points (1 px = 1 pt): a 1920 by 1080 slide is //! a 1920 by 1080 pt page, which is what spec §4.3 asks for. //! //! # Zero host imports //! //! wazero provides no WASI and no JS glue, so the module must import //! nothing (`tests/no_host_imports.rs`). Unlike the CRDT core this crate //! needs no `getrandom` backend override: nothing in its dependency graph //! draws entropy. Font lookup goes through `fontdb::Database::load_font_data` //! only; `load_system_fonts` is never called, and there is no filesystem //! to find anything on anyway. //! //! # Fonts are matched by family name //! //! `usvg` resolves `font-family` through `fontdb`'s CSS-like query against //! the loaded faces' family names (from the TTF `name` table), then weight //! and style. The SVG must therefore name the family exactly as the font //! does ("Liberation Serif", not "serif"). A family with no loaded face //! falls back to `usvg::Options::font_family`, set here to Liberation //! Sans; if that is missing too the text is dropped with a warning, not //! rendered in a substitute. pub mod abi; use std::sync::Arc; use krilla::geom::Size; use krilla::page::PageSettings; use krilla::{Document, SerializeSettings}; use krilla_svg::{SurfaceExt, SvgSettings}; /// Returns the ABI version this module exports. The Go host checks this on /// load and refuses a module it does not understand. Version 1 is the /// surface `abi.rs` exports today: alloc/free, render, and the out/err read /// pairs. The krilla swap changed nothing here, which is the point: the /// host and everything above it were untouched by it. #[no_mangle] pub extern "C" fn slides_pdf_abi_version() -> u32 { 1 } /// Why a render was refused. The message is what the host surfaces. #[derive(Debug)] pub struct RenderError(pub String); impl std::fmt::Display for RenderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } /// Builds a font database from raw TTF/OTF bytes. Collections load every /// face they contain. fn font_database(fonts: &[&[u8]]) -> fontdb::Database { let mut db = fontdb::Database::new(); for f in fonts { db.load_font_data(f.to_vec()); } // Generic families, should an SVG use them: everything maps onto the // faces we were given, never onto a name only a system would have. db.set_serif_family("Liberation Serif"); db.set_sans_serif_family("Liberation Sans"); db.set_monospace_family("Liberation Mono"); db.set_cursive_family("Liberation Serif"); db.set_fantasy_family("Liberation Sans"); db } /// Renders `svgs` as consecutive pages of one PDF, with text set in the /// faces given in `fonts`. An empty `svgs` is an error: a PDF with no /// pages is not a document any reader opens. pub fn render(svgs: &[&[u8]], fonts: &[&[u8]]) -> Result, RenderError> { if svgs.is_empty() { return Err(RenderError("no pages to render".to_string())); } // usvg's default is 96 dpi, which only matters for physical units // (mm, in, pt) inside the SVG; slide SVGs are in px. The page is // given the tree's own size below, so the px-to-pt mapping is 1:1. let options = usvg::Options { fontdb: Arc::new(font_database(fonts)), font_family: "Liberation Sans".to_string(), ..Default::default() }; // `embed_text` is what makes the text real glyphs rather than filled // outlines; it is krilla-svg's default and named here because it is // the property spec §4.3 asks for, not an incidental setting. // `filter_scale` is the rasterisation factor for SVG filter effects, // which slides emit none of. let svg_settings = SvgSettings { embed_text: true, filter_scale: 1.5, }; // XMP off. krilla writes an XMP packet by default, and this module // has nothing to put in one: no title, no author, no date (there is // no clock here). What it would carry instead is half a kilobyte of // RDF namespace URLs, which breaks the plainer property `export.go` // and its tests hold — a Slides PDF names nothing outside itself, so // grepping the bytes for "http" finds nothing. An identifier is not // a link, but a rule that needs that distinction explained is a rule // that stops being checked. let settings = SerializeSettings { xmp_metadata: false, ..Default::default() }; let mut document = Document::new_with(settings); for (i, svg) in svgs.iter().enumerate() { let tree = usvg::Tree::from_data(svg, &options) .map_err(|e| RenderError(format!("page {}: svg: {e}", i + 1)))?; // `Size::from_wh` rejects a non-finite or non-positive size, // which usvg will not produce from a parsed tree, but a page of // zero width is not something to write out either way. let size = Size::from_wh(tree.size().width(), tree.size().height()) .ok_or_else(|| RenderError(format!("page {}: page size is not drawable", i + 1)))?; let mut page = document.start_page_with(PageSettings::new(size)); let mut surface = page.surface(); surface.draw_svg(&tree, size, svg_settings); surface.finish(); page.finish(); } // krilla writes the whole document at the end, which is what lets it // share one subset of each face across every page. document .finish() .map_err(|e| RenderError(format!("krilla: {e}"))) } /// Splits a length-prefixed frame sequence: each item is a 4-byte /// big-endian length followed by that many bytes. Errors on a truncated /// frame rather than reading past the end. pub fn split_frames(buf: &[u8]) -> Result, RenderError> { let mut out = Vec::new(); let mut pos = 0usize; while pos < buf.len() { let Some(hdr) = buf.get(pos..pos + 4) else { return Err(RenderError(format!("truncated frame header at byte {pos}"))); }; let n = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as usize; pos += 4; let Some(item) = buf.get(pos..pos + n) else { return Err(RenderError(format!( "truncated frame at byte {pos}: wants {n} bytes, {} left", buf.len() - pos ))); }; out.push(item); pos += n; } Ok(out) }