oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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
40pub mod abi;
41
42use std::sync::Arc;
43
44use krilla::geom::Size;
45use krilla::page::PageSettings;
46use krilla::{Document, SerializeSettings};
47use 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]
55pub 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)]
61pub struct RenderError(pub String);
62
63impl 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.
71fn 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.
89pub 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.
152pub 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