| 1 | //! The Slides PDF module: SVG pages in, one PDF out, compiled to wasm and |
| 2 | //! run under wazero beside the CRDT core (`core/`). It wraps `krilla` (a |
| 3 | //! PDF writer) and `krilla-svg` (its SVG converter, over `usvg`) with a |
| 4 | //! `fontdb` that holds only the faces the host hands over — the Liberation |
| 5 | //! TTFs the layout engine measured with — so the PDF's text is real glyphs |
| 6 | //! from those exact fonts, subset and embedded, never paths and never a |
| 7 | //! system font. |
| 8 | //! |
| 9 | //! Until 2026-09-09 this was `svg2pdf`, whose repository (typst/svg2pdf) |
| 10 | //! was archived on 2026-07-10 naming krilla as its successor. The two are |
| 11 | //! by the same author over the same usvg tree, so the swap kept every |
| 12 | //! property the module had and improved two: a font subset is written |
| 13 | //! once per document rather than once per page (200 pages went from 1.72 |
| 14 | //! MB to 140 KB) and the render is about three times faster. The module |
| 15 | //! grew from 3.50 to 5.06 MiB. See spec §1 ruling 13. |
| 16 | //! |
| 17 | //! Each SVG becomes one page whose size is the SVG's own `width`/`height` |
| 18 | //! in user units read as PDF points (1 px = 1 pt): a 1920 by 1080 slide is |
| 19 | //! a 1920 by 1080 pt page, which is what spec §4.3 asks for. |
| 20 | //! |
| 21 | //! # Zero host imports |
| 22 | //! |
| 23 | //! wazero provides no WASI and no JS glue, so the module must import |
| 24 | //! nothing (`tests/no_host_imports.rs`). Unlike the CRDT core this crate |
| 25 | //! needs no `getrandom` backend override: nothing in its dependency graph |
| 26 | //! draws entropy. Font lookup goes through `fontdb::Database::load_font_data` |
| 27 | //! only; `load_system_fonts` is never called, and there is no filesystem |
| 28 | //! to find anything on anyway. |
| 29 | //! |
| 30 | //! # Fonts are matched by family name |
| 31 | //! |
| 32 | //! `usvg` resolves `font-family` through `fontdb`'s CSS-like query against |
| 33 | //! the loaded faces' family names (from the TTF `name` table), then weight |
| 34 | //! and style. The SVG must therefore name the family exactly as the font |
| 35 | //! does ("Liberation Serif", not "serif"). A family with no loaded face |
| 36 | //! falls back to `usvg::Options::font_family`, set here to Liberation |
| 37 | //! Sans; if that is missing too the text is dropped with a warning, not |
| 38 | //! rendered in a substitute. |
| 39 | |
| 40 | pub mod abi; |
| 41 | |
| 42 | use std::sync::Arc; |
| 43 | |
| 44 | use krilla::geom::Size; |
| 45 | use krilla::page::PageSettings; |
| 46 | use krilla::{Document, SerializeSettings}; |
| 47 | use krilla_svg::{SurfaceExt, SvgSettings}; |
| 48 | |
| 49 | /// Returns the ABI version this module exports. The Go host checks this on |
| 50 | /// load and refuses a module it does not understand. Version 1 is the |
| 51 | /// surface `abi.rs` exports today: alloc/free, render, and the out/err read |
| 52 | /// pairs. The krilla swap changed nothing here, which is the point: the |
| 53 | /// host and everything above it were untouched by it. |
| 54 | #[no_mangle] |
| 55 | pub extern "C" fn slides_pdf_abi_version() -> u32 { |
| 56 | 1 |
| 57 | } |
| 58 | |
| 59 | /// Why a render was refused. The message is what the host surfaces. |
| 60 | #[derive(Debug)] |
| 61 | pub struct RenderError(pub String); |
| 62 | |
| 63 | impl std::fmt::Display for RenderError { |
| 64 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 65 | f.write_str(&self.0) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// Builds a font database from raw TTF/OTF bytes. Collections load every |
| 70 | /// face they contain. |
| 71 | fn font_database(fonts: &[&[u8]]) -> fontdb::Database { |
| 72 | let mut db = fontdb::Database::new(); |
| 73 | for f in fonts { |
| 74 | db.load_font_data(f.to_vec()); |
| 75 | } |
| 76 | // Generic families, should an SVG use them: everything maps onto the |
| 77 | // faces we were given, never onto a name only a system would have. |
| 78 | db.set_serif_family("Liberation Serif"); |
| 79 | db.set_sans_serif_family("Liberation Sans"); |
| 80 | db.set_monospace_family("Liberation Mono"); |
| 81 | db.set_cursive_family("Liberation Serif"); |
| 82 | db.set_fantasy_family("Liberation Sans"); |
| 83 | db |
| 84 | } |
| 85 | |
| 86 | /// Renders `svgs` as consecutive pages of one PDF, with text set in the |
| 87 | /// faces given in `fonts`. An empty `svgs` is an error: a PDF with no |
| 88 | /// pages is not a document any reader opens. |
| 89 | pub fn render(svgs: &[&[u8]], fonts: &[&[u8]]) -> Result<Vec<u8>, RenderError> { |
| 90 | if svgs.is_empty() { |
| 91 | return Err(RenderError("no pages to render".to_string())); |
| 92 | } |
| 93 | |
| 94 | // usvg's default is 96 dpi, which only matters for physical units |
| 95 | // (mm, in, pt) inside the SVG; slide SVGs are in px. The page is |
| 96 | // given the tree's own size below, so the px-to-pt mapping is 1:1. |
| 97 | let options = usvg::Options { |
| 98 | fontdb: Arc::new(font_database(fonts)), |
| 99 | font_family: "Liberation Sans".to_string(), |
| 100 | ..Default::default() |
| 101 | }; |
| 102 | |
| 103 | // `embed_text` is what makes the text real glyphs rather than filled |
| 104 | // outlines; it is krilla-svg's default and named here because it is |
| 105 | // the property spec §4.3 asks for, not an incidental setting. |
| 106 | // `filter_scale` is the rasterisation factor for SVG filter effects, |
| 107 | // which slides emit none of. |
| 108 | let svg_settings = SvgSettings { |
| 109 | embed_text: true, |
| 110 | filter_scale: 1.5, |
| 111 | }; |
| 112 | |
| 113 | // XMP off. krilla writes an XMP packet by default, and this module |
| 114 | // has nothing to put in one: no title, no author, no date (there is |
| 115 | // no clock here). What it would carry instead is half a kilobyte of |
| 116 | // RDF namespace URLs, which breaks the plainer property `export.go` |
| 117 | // and its tests hold — a Slides PDF names nothing outside itself, so |
| 118 | // grepping the bytes for "http" finds nothing. An identifier is not |
| 119 | // a link, but a rule that needs that distinction explained is a rule |
| 120 | // that stops being checked. |
| 121 | let settings = SerializeSettings { |
| 122 | xmp_metadata: false, |
| 123 | ..Default::default() |
| 124 | }; |
| 125 | let mut document = Document::new_with(settings); |
| 126 | |
| 127 | for (i, svg) in svgs.iter().enumerate() { |
| 128 | let tree = usvg::Tree::from_data(svg, &options) |
| 129 | .map_err(|e| RenderError(format!("page {}: svg: {e}", i + 1)))?; |
| 130 | // `Size::from_wh` rejects a non-finite or non-positive size, |
| 131 | // which usvg will not produce from a parsed tree, but a page of |
| 132 | // zero width is not something to write out either way. |
| 133 | let size = Size::from_wh(tree.size().width(), tree.size().height()) |
| 134 | .ok_or_else(|| RenderError(format!("page {}: page size is not drawable", i + 1)))?; |
| 135 | let mut page = document.start_page_with(PageSettings::new(size)); |
| 136 | let mut surface = page.surface(); |
| 137 | surface.draw_svg(&tree, size, svg_settings); |
| 138 | surface.finish(); |
| 139 | page.finish(); |
| 140 | } |
| 141 | |
| 142 | // krilla writes the whole document at the end, which is what lets it |
| 143 | // share one subset of each face across every page. |
| 144 | document |
| 145 | .finish() |
| 146 | .map_err(|e| RenderError(format!("krilla: {e}"))) |
| 147 | } |
| 148 | |
| 149 | /// Splits a length-prefixed frame sequence: each item is a 4-byte |
| 150 | /// big-endian length followed by that many bytes. Errors on a truncated |
| 151 | /// frame rather than reading past the end. |
| 152 | pub fn split_frames(buf: &[u8]) -> Result<Vec<&[u8]>, RenderError> { |
| 153 | let mut out = Vec::new(); |
| 154 | let mut pos = 0usize; |
| 155 | while pos < buf.len() { |
| 156 | let Some(hdr) = buf.get(pos..pos + 4) else { |
| 157 | return Err(RenderError(format!("truncated frame header at byte {pos}"))); |
| 158 | }; |
| 159 | let n = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as usize; |
| 160 | pos += 4; |
| 161 | let Some(item) = buf.get(pos..pos + n) else { |
| 162 | return Err(RenderError(format!( |
| 163 | "truncated frame at byte {pos}: wants {n} bytes, {} left", |
| 164 | buf.len() - pos |
| 165 | ))); |
| 166 | }; |
| 167 | out.push(item); |
| 168 | pos += n; |
| 169 | } |
| 170 | Ok(out) |
| 171 | } |
| 172 | |