oficina / slides Public

spike: svg2pdf under wasm32 — viable

core/pdf (slides-pdf): svg2pdf 0.13 + usvg 0.45 (text) + fontdb fed TTF
bytes from linear memory, one page per SVG via to_chunk and pdf-writer,
C ABI matching core/src/abi.rs. Builds for wasm32-unknown-unknown with
zero host imports (3.5 MiB, reproducible), renders text as subset
embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode). Native
tests, no_host_imports, and a wazero spike test in internal/slides/pdfcore.
Spec §1 row 13 records the ruling: slice 3 proceeds with svg2pdf.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev b04cccbf8e7ffe4eba2fde88edc40849ef6616a2 parent ca0648c
12 files changed, +1676 −0
  • core/pdf/.gitignore +1 −0
  • core/pdf/Cargo.lock +833 −0
  • core/pdf/Cargo.toml +44 −0
  • core/pdf/src/abi.rs +183 −0
  • core/pdf/src/lib.rs +180 −0
  • core/pdf/tests/fonts/LiberationSerif-Regular.ttf binary
  • core/pdf/tests/no_host_imports.rs +52 −0
  • core/pdf/tests/render.rs +164 −0
  • docs/superpowers/specs/2026-09-07-slides-design.md +1 −0
  • internal/slides/pdfcore/spike_test.go +190 −0
  • internal/slides/pdfcore/testdata/slides_pdf.wasm binary
  • scripts/build-pdf-wasm.sh +28 −0

Intent

Attributed to this commit: 10.5 g CO₂e (1317286 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/core/pdf/.gitignore b/core/pdf/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/core/pdf/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/core/pdf/Cargo.lock b/core/pdf/Cargo.lock
new file mode 100644
index 0000000..b0764e1
--- /dev/null
+++ b/core/pdf/Cargo.lock
@@ -0,0 +1,833 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "data-url"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "float-cmp"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "fontconfig-parser"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
+dependencies = [
+ "roxmltree",
+]
+
+[[package]]
+name = "fontdb"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser",
+]
+
+[[package]]
+name = "gif"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "color_quant",
+ "gif",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "imagesize"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "kurbo"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
+[[package]]
+name = "kurbo"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "polycool",
+ "smallvec",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pdf-writer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df03c7d216de06f93f398ef06f1385a60f2c597bb96f8195c8d98e08a26b1d5"
+dependencies = [
+ "bitflags 2.13.1",
+ "itoa",
+ "memchr",
+ "ryu",
+]
+
+[[package]]
+name = "pico-args"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polycool"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.39.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
+dependencies = [
+ "bytemuck",
+ "font-types",
+]
+
+[[package]]
+name = "resvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
+dependencies = [
+ "log",
+ "pico-args",
+ "rgb",
+ "svgtypes",
+ "tiny-skia",
+ "usvg",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustybuzz"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "core_maths",
+ "log",
+ "smallvec",
+ "ttf-parser",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
+ "unicode-properties",
+ "unicode-script",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "simplecss"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "skrifa"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
+[[package]]
+name = "slides-pdf"
+version = "0.1.0"
+dependencies = [
+ "flate2",
+ "fontdb",
+ "pdf-writer",
+ "svg2pdf",
+ "usvg",
+ "wasmparser",
+]
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+dependencies = [
+ "float-cmp",
+]
+
+[[package]]
+name = "subsetter"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
+dependencies = [
+ "kurbo 0.13.1",
+ "rustc-hash",
+ "skrifa",
+ "write-fonts",
+]
+
+[[package]]
+name = "svg2pdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22"
+dependencies = [
+ "fontdb",
+ "image",
+ "log",
+ "miniz_oxide 0.8.9",
+ "once_cell",
+ "pdf-writer",
+ "resvg",
+ "siphasher",
+ "subsetter",
+ "ttf-parser",
+ "usvg",
+]
+
+[[package]]
+name = "svgtypes"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
+dependencies = [
+ "kurbo 0.11.3",
+ "siphasher",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "png 0.17.16",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+dependencies = [
+ "core_maths",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
+
+[[package]]
+name = "unicode-ccc"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "unicode-script"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+
+[[package]]
+name = "unicode-vo"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
+
+[[package]]
+name = "usvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
+dependencies = [
+ "base64",
+ "data-url",
+ "flate2",
+ "fontdb",
+ "imagesize",
+ "kurbo 0.11.3",
+ "log",
+ "pico-args",
+ "roxmltree",
+ "rustybuzz",
+ "simplecss",
+ "siphasher",
+ "strict-num",
+ "svgtypes",
+ "tiny-skia-path",
+ "unicode-bidi",
+ "unicode-script",
+ "unicode-vo",
+ "xmlwriter",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasmparser"
+version = "0.258.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313"
+dependencies = [
+ "bitflags 2.13.1",
+ "hashbrown",
+ "indexmap",
+ "semver",
+ "serde",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "write-fonts"
+version = "0.48.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
+dependencies = [
+ "font-types",
+ "indexmap",
+ "kurbo 0.13.1",
+ "log",
+ "read-fonts",
+]
+
+[[package]]
+name = "xmlwriter"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
+
+[[package]]
+name = "zune-core"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
diff --git a/core/pdf/Cargo.toml b/core/pdf/Cargo.toml
new file mode 100644
index 0000000..8b38095
--- /dev/null
+++ b/core/pdf/Cargo.toml
@@ -0,0 +1,44 @@
+[package]
+name = "slides-pdf"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+# svg2pdf's "text" feature is what embeds glyphs (fonts subset by
+# `subsetter`, CID fonts written by pdf-writer); without it every <text>
+# is flattened to paths. "image" is the pure-Rust `image` crate for raster
+# <image> hrefs (JPEG, PNG, GIF). "filters" (tiny-skia rasterisation of
+# SVG filter effects) is left off: slides emit none and it is the largest
+# optional piece.
+svg2pdf = { version = "=0.13.0", default-features = false, features = ["text", "image"] }
+# svg2pdf 0.13 pins usvg 0.45 and fontdb 0.23; these are named directly so
+# the crate can build a fontdb::Database from bytes handed over the ABI and
+# parse the SVG itself. usvg's default features add system font loading
+# (fs, fontconfig, memmap) which has nothing to memmap on wasm32; they are
+# left off here, though svg2pdf's own `fontdb` dependency still requests
+# fontdb's defaults (fs, memmap, fontconfig) and Cargo features are
+# additive across the graph. That compiles for wasm32-unknown-unknown
+# because memmap2 and fontconfig-parser both carry stubs for non-unix,
+# non-windows targets, and nothing in this crate ever calls
+# `load_system_fonts`: faces come only from `Database::load_font_data`.
+usvg = { version = "=0.45.1", default-features = false, features = ["text"] }
+fontdb = { version = "=0.23.0", default-features = false }
+pdf-writer = "=0.12.1"
+
+# No getrandom: unlike the CRDT core (yrs -> fastrand -> getrandom), nothing
+# in this graph draws entropy (`cargo tree -i getrandom` names no crate), so
+# there is no backend to override and no `__getrandom_v03_custom` hook to
+# define. The `--cfg getrandom_backend="custom"` in the root .cargo/config
+# is inert for this crate.
+
+[profile.release]
+opt-level = "s"
+lto = true
+panic = "abort"
+
+[dev-dependencies]
+wasmparser = "=0.258.0"
+flate2 = "1"
diff --git a/core/pdf/src/abi.rs b/core/pdf/src/abi.rs
new file mode 100644
index 0000000..bcc9b3a
--- /dev/null
+++ b/core/pdf/src/abi.rs
@@ -0,0 +1,183 @@
+//! The C ABI the Go host calls through wazero, following `core/src/abi.rs`:
+//! integers and linear-memory offsets only, every host pointer bounds-
+//! checked before a slice is built over it, and the two results of the
+//! last `slides_pdf_render` call held in module memory behind `_len`/`_read`
+//! pairs.
+//!
+//! There are no handles: a render is one call in, one PDF out, and the
+//! host instantiates a fresh module per export job (a trap or a timeout
+//! discards the instance, as in the CRDT host). `OUT` and `ERR` are
+//! therefore module-global rather than per-handle.
+//!
+//! # Inputs
+//!
+//! `svgs_ptr/len` and `fonts_ptr/len` each describe a sequence of
+//! length-prefixed frames (4-byte big-endian length, then the bytes):
+//! one frame per SVG page, one frame per TTF. The host writes them into
+//! buffers from `slides_pdf_alloc`.
+//!
+//! # Results
+//!
+//! `slides_pdf_render` returns 0 on success (PDF bytes in `out`, `err`
+//! cleared) or a negative code (message in `err`, `out` cleared).
+
+use crate::{render, split_frames, RenderError};
+use std::cell::RefCell;
+
+thread_local! {
+ static OUT: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
+ static ERR: RefCell<String> = const { RefCell::new(String::new()) };
+}
+
+/// The render was refused; the message is in the `err` read pair.
+pub const ERR_RENDER: i32 = -1;
+pub const ERR_BUFFER_TOO_SMALL: i32 = -4;
+/// A `ptr`/`len` pair this module's linear memory cannot satisfy. See
+/// `in_bounds`.
+pub const ERR_INVALID_LENGTH: i32 = -5;
+
+fn set_result(r: Result<Vec<u8>, RenderError>) -> i32 {
+ match r {
+ Ok(pdf) => {
+ OUT.with(|o| *o.borrow_mut() = pdf);
+ ERR.with(|e| e.borrow_mut().clear());
+ 0
+ }
+ Err(e) => {
+ ERR.with(|m| *m.borrow_mut() = e.0);
+ OUT.with(|o| o.borrow_mut().clear());
+ ERR_RENDER
+ }
+ }
+}
+
+/// Renders the frames and files the result. Native callers (tests) use
+/// this; the wasm export below unpacks the pointers and calls it.
+pub fn render_frames(svgs: &[u8], fonts: &[u8]) -> i32 {
+ let r = (|| {
+ let svgs = split_frames(svgs)?;
+ let fonts = split_frames(fonts)?;
+ render(&svgs, &fonts)
+ })();
+ set_result(r)
+}
+
+fn layout_for(len: u32) -> Option<std::alloc::Layout> {
+ std::alloc::Layout::from_size_align(len.max(1) as usize, 1).ok()
+}
+
+#[cfg(target_arch = "wasm32")]
+fn memory_len_bytes() -> u64 {
+ const WASM_PAGE_BYTES: u64 = 65536;
+ core::arch::wasm32::memory_size(0) as u64 * WASM_PAGE_BYTES
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn memory_len_bytes() -> u64 {
+ u64::MAX
+}
+
+/// True if `ptr..ptr+len` is representable as a Rust slice and (on wasm32)
+/// fully inside this module's currently instantiated linear memory.
+fn in_bounds(ptr: u32, len: u32) -> bool {
+ if len as u64 > isize::MAX as u64 {
+ return false;
+ }
+ match (ptr as u64).checked_add(len as u64) {
+ Some(end) => end <= memory_len_bytes(),
+ None => false,
+ }
+}
+
+/// Allocates `len` bytes for the host to write into; 0 on failure, which
+/// the host must check.
+#[no_mangle]
+pub extern "C" fn slides_pdf_alloc(len: u32) -> u32 {
+ match layout_for(len) {
+ // SAFETY: `layout_for` never returns a zero-size Layout.
+ Some(layout) => unsafe { std::alloc::alloc(layout) as u32 },
+ None => 0,
+ }
+}
+
+/// # Safety
+///
+/// `ptr` must be 0 or a value returned by `slides_pdf_alloc` with this
+/// exact `len`, not yet freed.
+#[no_mangle]
+pub unsafe extern "C" fn slides_pdf_free(ptr: u32, len: u32) {
+ if ptr == 0 {
+ return;
+ }
+ if let Some(layout) = layout_for(len) {
+ std::alloc::dealloc(ptr as *mut u8, layout);
+ }
+}
+
+/// # Safety
+///
+/// Both pointer/length pairs must describe byte ranges inside this
+/// module's linear memory that are not mutated during the call (checked
+/// by `in_bounds` before any slice is constructed).
+#[no_mangle]
+pub unsafe extern "C" fn slides_pdf_render(
+ svgs_ptr: u32,
+ svgs_len: u32,
+ fonts_ptr: u32,
+ fonts_len: u32,
+) -> i32 {
+ if !in_bounds(svgs_ptr, svgs_len) || !in_bounds(fonts_ptr, fonts_len) {
+ return ERR_INVALID_LENGTH;
+ }
+ let svgs = std::slice::from_raw_parts(svgs_ptr as *const u8, svgs_len as usize);
+ let fonts = std::slice::from_raw_parts(fonts_ptr as *const u8, fonts_len as usize);
+ render_frames(svgs, fonts)
+}
+
+fn len_code(bytes: &[u8]) -> i32 {
+ bytes.len().try_into().unwrap_or(i32::MAX)
+}
+
+/// # Safety
+///
+/// `out_ptr`/`out_len` must be a writable range inside linear memory
+/// (checked by `in_bounds` before any write).
+unsafe fn read_into(bytes: &[u8], out_ptr: u32, out_len: u32) -> i32 {
+ if bytes.len() > out_len as usize {
+ return ERR_BUFFER_TOO_SMALL;
+ }
+ if !in_bounds(out_ptr, out_len) {
+ return ERR_INVALID_LENGTH;
+ }
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr as *mut u8, bytes.len());
+ len_code(bytes)
+}
+
+/// Byte length of the last render's PDF (0 if none or it failed).
+#[no_mangle]
+pub extern "C" fn slides_pdf_out_len() -> i32 {
+ OUT.with(|o| len_code(&o.borrow()))
+}
+
+/// # Safety
+///
+/// See `read_into`.
+#[no_mangle]
+pub unsafe extern "C" fn slides_pdf_out_read(out_ptr: u32, out_len: u32) -> i32 {
+ OUT.with(|o| read_into(&o.borrow(), out_ptr, out_len))
+}
+
+/// Byte length of the last render's refusal message (0 if none or it
+/// succeeded).
+#[no_mangle]
+pub extern "C" fn slides_pdf_err_len() -> i32 {
+ ERR.with(|e| len_code(e.borrow().as_bytes()))
+}
+
+/// # Safety
+///
+/// See `read_into`.
+#[no_mangle]
+pub unsafe extern "C" fn slides_pdf_err_read(out_ptr: u32, out_len: u32) -> i32 {
+ ERR.with(|e| read_into(e.borrow().as_bytes(), out_ptr, out_len))
+}
diff --git a/core/pdf/src/lib.rs b/core/pdf/src/lib.rs
new file mode 100644
index 0000000..6f28bd7
--- /dev/null
+++ b/core/pdf/src/lib.rs
@@ -0,0 +1,180 @@
+//! The Slides PDF module: SVG pages in, one PDF out, compiled to wasm and
+//! run under wazero beside the CRDT core (`core/`). It wraps `svg2pdf`
+//! (typst's SVG to PDF 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.
+//!
+//! 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::collections::HashMap;
+use std::sync::Arc;
+
+use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, TextStr};
+use svg2pdf::ConversionOptions;
+
+/// 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.
+#[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<Vec<u8>, 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. svg2pdf's
+ // `PageOptions` dpi (72) is what maps px to pt in `to_pdf`; with
+ // `to_chunk` the mapping is ours, below, and it is 1:1.
+ let options = usvg::Options {
+ fontdb: Arc::new(font_database(fonts)),
+ font_family: "Liberation Sans".to_string(),
+ ..Default::default()
+ };
+
+ let conversion = ConversionOptions {
+ compress: true,
+ raster_scale: 1.5,
+ embed_text: true,
+ pdfa: false,
+ };
+
+ let mut alloc = Ref::new(1);
+ let catalog_id = alloc.bump();
+ let page_tree_id = alloc.bump();
+
+ let mut pdf = Pdf::new();
+ let mut page_ids = Vec::with_capacity(svgs.len());
+
+ 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)))?;
+ let (chunk, x_id) = svg2pdf::to_chunk(&tree, conversion)
+ .map_err(|e| RenderError(format!("page {}: svg2pdf: {e}", i + 1)))?;
+
+ // Renumber the chunk's objects into this document's sequence.
+ let mut map: HashMap<Ref, Ref> = HashMap::new();
+ let chunk = chunk.renumber(|old| *map.entry(old).or_insert_with(|| alloc.bump()));
+ let x_id = *map
+ .get(&x_id)
+ .ok_or_else(|| RenderError(format!("page {}: xobject ref lost", i + 1)))?;
+
+ let w = tree.size().width();
+ let h = tree.size().height();
+
+ let page_id = alloc.bump();
+ let content_id = alloc.bump();
+ page_ids.push(page_id);
+
+ let x_name = Name(b"S1");
+ let mut page = pdf.page(page_id);
+ page.media_box(Rect::new(0.0, 0.0, w, h));
+ page.parent(page_tree_id);
+ page.contents(content_id);
+ let mut resources = page.resources();
+ resources.x_objects().pair(x_name, x_id);
+ resources.finish();
+ page.finish();
+
+ // The XObject is one point square; scale it to the page.
+ let mut content = Content::new();
+ content
+ .transform([w, 0.0, 0.0, h, 0.0, 0.0])
+ .x_object(x_name);
+ pdf.stream(content_id, &content.finish());
+ pdf.extend(&chunk);
+ }
+
+ pdf.catalog(catalog_id).pages(page_tree_id);
+ pdf.pages(page_tree_id)
+ .kids(page_ids.iter().copied())
+ .count(page_ids.len() as i32);
+ let info_id = alloc.bump();
+ pdf.document_info(info_id)
+ .producer(TextStr("slides-pdf (svg2pdf)"));
+
+ Ok(pdf.finish())
+}
+
+/// 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<Vec<&[u8]>, 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)
+}
diff --git a/core/pdf/tests/fonts/LiberationSerif-Regular.ttf b/core/pdf/tests/fonts/LiberationSerif-Regular.ttf
new file mode 100644
index 0000000..d7622a3
Binary files /dev/null and b/core/pdf/tests/fonts/LiberationSerif-Regular.ttf differ
diff --git a/core/pdf/tests/no_host_imports.rs b/core/pdf/tests/no_host_imports.rs
new file mode 100644
index 0000000..77d4c95
--- /dev/null
+++ b/core/pdf/tests/no_host_imports.rs
@@ -0,0 +1,52 @@
+use std::path::Path;
+
+#[test]
+fn no_host_imports() {
+ let wasm_path = "target/wasm32-unknown-unknown/release/slides_pdf.wasm";
+
+ // SLIDES_REQUIRE_WASM=1 is set by the CI gate, which always builds the
+ // wasm artifact before running this test. Left unset, a bare
+ // `cargo test` on a fresh clone skips rather than failing.
+ let require_wasm = std::env::var("SLIDES_REQUIRE_WASM").as_deref() == Ok("1");
+
+ if !Path::new(wasm_path).exists() {
+ if require_wasm {
+ panic!(
+ "FAILED (SLIDES_REQUIRE_WASM=1): wasm artifact not found at {}. \
+ The gate must build it before running this test.",
+ wasm_path
+ );
+ }
+ eprintln!(
+ "SKIPPED (SLIDES_REQUIRE_WASM unset): wasm artifact not found at {}. \
+ Run ./scripts/build-pdf-wasm.sh first.",
+ wasm_path
+ );
+ return;
+ }
+
+ let wasm_bytes = std::fs::read(wasm_path).expect("Failed to read wasm file");
+ let parser = wasmparser::Parser::new(0);
+
+ let mut imports = Vec::new();
+
+ for payload in parser.parse_all(&wasm_bytes) {
+ let payload = payload.expect("Failed to parse wasm module");
+
+ if let wasmparser::Payload::ImportSection(reader) = payload {
+ for import_result in reader.into_imports() {
+ let import = import_result.expect("Failed to parse import");
+ imports.push(format!("{}.{}", import.module, import.name));
+ }
+ }
+ }
+
+ if !imports.is_empty() {
+ panic!(
+ "WASM module has host imports (breaks Go/wazero compatibility):\n {}",
+ imports.join("\n ")
+ );
+ }
+
+ eprintln!("PASSED (checked {} bytes, 0 imports)", wasm_bytes.len());
+}
diff --git a/core/pdf/tests/render.rs b/core/pdf/tests/render.rs
new file mode 100644
index 0000000..2c3ff88
--- /dev/null
+++ b/core/pdf/tests/render.rs
@@ -0,0 +1,164 @@
+//! Native proof that the module produces a PDF with the text as embedded
+//! glyphs from the font it was given, not as paths.
+
+use std::io::Read;
+
+const FONT: &[u8] = include_bytes!("fonts/LiberationSerif-Regular.ttf");
+
+const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
+ <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
+ <text x="120" y="600" font-family="Liberation Serif" font-size="96" fill="#111">Hello slides</text>
+</svg>"##;
+
+fn frames(items: &[&[u8]]) -> Vec<u8> {
+ let mut out = Vec::new();
+ for it in items {
+ out.extend_from_slice(&(it.len() as u32).to_be_bytes());
+ out.extend_from_slice(it);
+ }
+ out
+}
+
+/// Inflates every FlateDecode stream in the PDF and returns the PDF with
+/// those streams replaced by their plain contents, so text operators and
+/// font dictionaries can be grepped for. Streams are located by their
+/// dictionary's direct `/Length` (pdf-writer always writes one).
+fn inflate_streams(pdf: &[u8]) -> Vec<u8> {
+ let mut out = Vec::new();
+ let mut pos = 0;
+ while let Some(i) = find(&pdf[pos..], b">>\nstream\n") {
+ let dict_end = pos + i;
+ let start = dict_end + b">>\nstream\n".len();
+ // The stream's own dictionary starts at the enclosing "N 0 obj"
+ // header, not at the last "<<" (that could be a nested dict).
+ let dict_start = pdf[..dict_end]
+ .windows(6)
+ .rposition(|w| w == b" 0 obj")
+ .unwrap_or(0);
+ let dict = &pdf[dict_start..dict_end];
+ let len = find(dict, b"/Length ")
+ .map(|j| {
+ let digits: String = dict[j + 8..]
+ .iter()
+ .take_while(|b| b.is_ascii_digit())
+ .map(|&b| b as char)
+ .collect();
+ digits.parse::<usize>().expect("/Length")
+ })
+ .expect("stream dictionary without /Length");
+ let end = start + len;
+ out.extend_from_slice(&pdf[pos..start]);
+ let raw = &pdf[start..end];
+ if find(dict, b"FlateDecode").is_some() {
+ let mut z = flate2::read::ZlibDecoder::new(raw);
+ let mut plain = Vec::new();
+ match z.read_to_end(&mut plain) {
+ Ok(_) => out.extend_from_slice(&plain),
+ Err(_) => out.extend_from_slice(raw),
+ }
+ } else {
+ out.extend_from_slice(raw);
+ }
+ pos = end;
+ }
+ out.extend_from_slice(&pdf[pos..]);
+ out
+}
+
+fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
+ hay.windows(needle.len()).position(|w| w == needle)
+}
+
+fn count(hay: &[u8], needle: &[u8]) -> usize {
+ hay.windows(needle.len()).filter(|w| *w == needle).count()
+}
+
+#[test]
+fn renders_text_as_embedded_glyphs() {
+ let pdf = slides_pdf::render(&[SVG.as_bytes()], &[FONT]).expect("render");
+ assert!(pdf.starts_with(b"%PDF-"), "no PDF header");
+
+ // Page count: /Type /Page objects less the /Type /Pages tree node.
+ let pages = count(&pdf, b"/Type /Page") - count(&pdf, b"/Type /Pages");
+ assert_eq!(pages, 1, "expected one page");
+ assert!(find(&pdf, b"/Count 1").is_some());
+ assert!(
+ find(&pdf, b"/MediaBox [0 0 1920 1080]").is_some(),
+ "page is 1920x1080 pt"
+ );
+
+ // An embedded font program (TrueType -> FontFile2; CFF -> FontFile3).
+ assert!(
+ find(&pdf, b"/FontFile2").is_some() || find(&pdf, b"/FontFile3").is_some(),
+ "no embedded font program"
+ );
+ assert!(find(&pdf, b"/Type /Font").is_some(), "no /Font dictionary");
+ assert!(
+ find(&pdf, b"LiberationSerif").is_some(),
+ "font name not Liberation Serif"
+ );
+
+ // Text drawn with a text-showing operator, not as filled paths.
+ let plain = inflate_streams(&pdf);
+ let has_show = find(&plain, b" Tj").is_some() || find(&plain, b" TJ").is_some();
+ assert!(has_show, "no Tj/TJ operator in any content stream");
+ assert!(
+ find(&plain, b"BT").is_some() && find(&plain, b" Tf").is_some(),
+ "no BT/Tf"
+ );
+
+ // ToUnicode so the text extracts as "Hello slides".
+ assert!(find(&pdf, b"/ToUnicode").is_some(), "no ToUnicode CMap");
+
+ std::fs::create_dir_all("target").ok();
+ std::fs::write("target/spike.pdf", &pdf).ok();
+ eprintln!("PDF {} bytes, {} pages", pdf.len(), pages);
+}
+
+#[test]
+fn two_svgs_make_two_pages() {
+ let pdf = slides_pdf::render(&[SVG.as_bytes(), SVG.as_bytes()], &[FONT]).expect("render");
+ let pages = count(&pdf, b"/Type /Page") - count(&pdf, b"/Type /Pages");
+ assert_eq!(pages, 2);
+ assert!(find(&pdf, b"/Count 2").is_some());
+}
+
+#[test]
+fn abi_frames_round_trip() {
+ let svgs = frames(&[SVG.as_bytes()]);
+ let fonts = frames(&[FONT]);
+ assert_eq!(slides_pdf::abi::render_frames(&svgs, &fonts), 0);
+ assert!(slides_pdf::abi::slides_pdf_out_len() > 0);
+ assert_eq!(slides_pdf::abi::slides_pdf_err_len(), 0);
+
+ // A truncated frame is a refusal with a message, not a panic.
+ let bad = &svgs[..svgs.len() - 3];
+ assert_eq!(
+ slides_pdf::abi::render_frames(bad, &fonts),
+ slides_pdf::abi::ERR_RENDER
+ );
+ assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0);
+ assert!(slides_pdf::abi::slides_pdf_err_len() > 0);
+
+ // No pages is a refusal too.
+ assert_eq!(
+ slides_pdf::abi::render_frames(&[], &fonts),
+ slides_pdf::abi::ERR_RENDER
+ );
+}
+
+#[test]
+fn raster_image_is_embedded() {
+ // A 2x2 opaque red PNG, so the `image` feature is exercised end to end.
+ let png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==";
+ let svg = format!(
+ r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080">
+ <image x="0" y="0" width="400" height="400" xlink:href="data:image/png;base64,{png}"/>
+</svg>"##
+ );
+ let pdf = slides_pdf::render(&[svg.as_bytes()], &[FONT]).expect("render");
+ assert!(
+ find(&pdf, b"/Subtype /Image").is_some(),
+ "no image XObject in the PDF"
+ );
+}
diff --git a/docs/superpowers/specs/2026-09-07-slides-design.md b/docs/superpowers/specs/2026-09-07-slides-design.md
index 4d05d1e..13bf259 100644
--- a/docs/superpowers/specs/2026-09-07-slides-design.md
+++ b/docs/superpowers/specs/2026-09-07-slides-design.md
@@ -81,6 +81,7 @@ calls.
| 10 | Bytes (images) live in the **platform object store**, referenced by `blobs.Ref`; never on the instance disk, never in a column beyond the 4 KiB inline rule. | SQLite blobs (the instance hibernates and its disk is ephemeral; rastrillo §2). | rastrillo rule, adopted |
| 11 | Repo `oficina/slides`, module `amadan.net/oficina/slides`, scaffolded by `rastrillo new` v0.26.0 (day theme, topbar shell). | Spanish name (`presentaciones`); the suite names generic apps in English. | Session, by the naming precedent |
| 12 | `href` stored as `{href}`; marks named after Y attributes | plain-string href (does not survive y-prosemirror) | Session, 2026-09-07, from the Task 2 finding |
+| 13 | PDF via Rust svg2pdf under wazero: **viable** — `svg2pdf` 0.13 with `usvg` 0.45 (text) and `fontdb` fed the Liberation TTFs from linear memory builds for wasm32-unknown-unknown with zero host imports, a 3.5 MiB reproducible module, and renders text as subset embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode), 200 pages in 140 ms; slice 3 proceeds with svg2pdf (`core/pdf`, `internal/slides/pdfcore`). | The Go paint-list writer (`pdfgo`), §4.3's recorded fallback: stays recorded, not built. | Session, 2026-09-08, Task 1 spike |
---
diff --git a/internal/slides/pdfcore/spike_test.go b/internal/slides/pdfcore/spike_test.go
new file mode 100644
index 0000000..af2d2e1
--- /dev/null
+++ b/internal/slides/pdfcore/spike_test.go
@@ -0,0 +1,190 @@
+// Package pdfcore will host the Rust PDF module (core/pdf) under wazero.
+// This file is the slice 3 Task 1 spike: it proves the module built by
+// scripts/build-pdf-wasm.sh instantiates with no host imports, renders
+// one SVG with <text> in Liberation Serif, and returns a PDF whose text is
+// an embedded, subset TrueType font. The real host lands in Task 4.
+package pdfcore
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/binary"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+)
+
+//go:embed testdata/slides_pdf.wasm
+var moduleWASM []byte
+
+const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
+ <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
+ <text x="120" y="600" font-family="Liberation Serif" font-size="96" fill="#111">Hello slides</text>
+</svg>`
+
+// frames encodes items as 4-byte big-endian length-prefixed frames, the
+// input shape of slides_pdf_render.
+func frames(items ...[]byte) []byte {
+ var b bytes.Buffer
+ for _, it := range items {
+ binary.Write(&b, binary.BigEndian, uint32(len(it)))
+ b.Write(it)
+ }
+ return b.Bytes()
+}
+
+func call(t *testing.T, ctx context.Context, mod api.Module, name string, params ...uint64) uint64 {
+ t.Helper()
+ res, err := mod.ExportedFunction(name).Call(ctx, params...)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if len(res) == 0 {
+ return 0
+ }
+ return res[0]
+}
+
+// alloc copies data into the module's linear memory via slides_pdf_alloc
+// and returns the pointer.
+func alloc(t *testing.T, ctx context.Context, mod api.Module, data []byte) uint64 {
+ t.Helper()
+ ptr := call(t, ctx, mod, "slides_pdf_alloc", uint64(len(data)))
+ if ptr == 0 {
+ t.Fatalf("slides_pdf_alloc(%d) returned 0", len(data))
+ }
+ if !mod.Memory().Write(uint32(ptr), data) {
+ t.Fatalf("writing %d bytes at %d: out of range", len(data), ptr)
+ }
+ return ptr
+}
+
+// read drains one of the _len/_read pairs.
+func read(t *testing.T, ctx context.Context, mod api.Module, pair string) []byte {
+ t.Helper()
+ n := int32(call(t, ctx, mod, "slides_pdf_"+pair+"_len"))
+ if n < 0 {
+ t.Fatalf("%s_len: %d", pair, n)
+ }
+ if n == 0 {
+ return nil
+ }
+ ptr := call(t, ctx, mod, "slides_pdf_alloc", uint64(n))
+ defer call(t, ctx, mod, "slides_pdf_free", ptr, uint64(n))
+ got := int32(call(t, ctx, mod, "slides_pdf_"+pair+"_read", ptr, uint64(n)))
+ if got != n {
+ t.Fatalf("%s_read: wrote %d of %d", pair, got, n)
+ }
+ out, ok := mod.Memory().Read(uint32(ptr), uint32(n))
+ if !ok {
+ t.Fatalf("reading %d bytes at %d: out of range", n, ptr)
+ }
+ return bytes.Clone(out)
+}
+
+func TestSpikeRendersUnderWazero(t *testing.T) {
+ font, err := os.ReadFile("../../../core/pdf/tests/fonts/LiberationSerif-Regular.ttf")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true))
+ defer rt.Close(ctx)
+
+ compileStart := time.Now()
+ compiled, err := rt.CompileModule(ctx, moduleWASM)
+ if err != nil {
+ t.Fatalf("compile: %v", err)
+ }
+ compileTook := time.Since(compileStart)
+
+ // The zero-imports contract, checked at the host too: wazero would
+ // refuse to instantiate a module with unsatisfied imports, but saying
+ // which import is clearer than "module not found".
+ if imports := compiled.ImportedFunctions(); len(imports) != 0 {
+ for _, f := range imports {
+ m, n, _ := f.Import()
+ t.Errorf("host import: %s.%s", m, n)
+ }
+ t.FailNow()
+ }
+
+ mod, err := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig().WithName(""))
+ if err != nil {
+ t.Fatalf("instantiate: %v", err)
+ }
+ defer mod.Close(ctx)
+
+ if v := call(t, ctx, mod, "slides_pdf_abi_version"); v != 1 {
+ t.Fatalf("abi version %d, want 1", v)
+ }
+
+ svgs := frames([]byte(svg))
+ fonts := frames(font)
+ svgsPtr := alloc(t, ctx, mod, svgs)
+ fontsPtr := alloc(t, ctx, mod, fonts)
+
+ cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ renderStart := time.Now()
+ rc := int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)), fontsPtr, uint64(len(fonts))))
+ renderTook := time.Since(renderStart)
+ if rc != 0 {
+ t.Fatalf("slides_pdf_render: %d: %s", rc, read(t, ctx, mod, "err"))
+ }
+ if errMsg := read(t, ctx, mod, "err"); len(errMsg) != 0 {
+ t.Fatalf("err set on success: %s", errMsg)
+ }
+
+ pdf := read(t, ctx, mod, "out")
+ if !bytes.HasPrefix(pdf, []byte("%PDF-1.7")) {
+ t.Fatalf("not a PDF 1.7: %q", pdf[:min(16, len(pdf))])
+ }
+ pages := bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
+ if pages != 1 {
+ t.Errorf("pages = %d, want 1", pages)
+ }
+ if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) {
+ t.Error("page is not 1920x1080 pt")
+ }
+ if !bytes.Contains(pdf, []byte("/FontFile2")) && !bytes.Contains(pdf, []byte("/FontFile3")) {
+ t.Error("no embedded font program")
+ }
+ if !bytes.Contains(pdf, []byte("/Subtype /Type0")) || !bytes.Contains(pdf, []byte("LiberationSerif")) {
+ t.Error("no Type0 Liberation Serif font")
+ }
+ if !bytes.Contains(pdf, []byte("/ToUnicode")) {
+ t.Error("no ToUnicode CMap")
+ }
+
+ // A second render on the same instance is a clean replacement of out.
+ rc = int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)), fontsPtr, uint64(len(fonts))))
+ if rc != 0 {
+ t.Fatalf("second render: %d", rc)
+ }
+ if again := read(t, ctx, mod, "out"); !bytes.Equal(again, pdf) {
+ t.Errorf("second render differs (%d vs %d bytes)", len(again), len(pdf))
+ }
+
+ // A truncated frame is a refusal with a message, never a trap.
+ rc = int32(call(t, cctx, mod, "slides_pdf_render", svgsPtr, uint64(len(svgs)-3), fontsPtr, uint64(len(fonts))))
+ if rc != -1 {
+ t.Errorf("truncated frame: rc %d, want -1", rc)
+ }
+ if msg := read(t, ctx, mod, "err"); !bytes.Contains(msg, []byte("truncated")) {
+ t.Errorf("truncated frame: err %q", msg)
+ }
+
+ if os.Getenv("SPIKE_WRITE_PDF") != "" {
+ os.WriteFile(os.Getenv("SPIKE_WRITE_PDF"), pdf, 0o644)
+ }
+ fmt.Printf("module %d bytes (%.2f MiB); compile %s; render %s; PDF %d bytes, %d page(s); memory %d pages\n",
+ len(moduleWASM), float64(len(moduleWASM))/(1<<20), compileTook.Round(time.Millisecond),
+ renderTook.Round(time.Millisecond), len(pdf), pages, mod.Memory().Size()/65536)
+}
diff --git a/internal/slides/pdfcore/testdata/slides_pdf.wasm b/internal/slides/pdfcore/testdata/slides_pdf.wasm
new file mode 100755
index 0000000..e4cb47d
Binary files /dev/null and b/internal/slides/pdfcore/testdata/slides_pdf.wasm differ
diff --git a/scripts/build-pdf-wasm.sh b/scripts/build-pdf-wasm.sh
new file mode 100755
index 0000000..95e2ed0
--- /dev/null
+++ b/scripts/build-pdf-wasm.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Build the PDF crate (core/pdf) for wazero, the same way build-wasm.sh
+# builds the CRDT core: plain wasm32 with a C ABI, no wasm-bindgen, no
+# wasm-pack, no host imports.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+cargo clippy --manifest-path core/pdf/Cargo.toml --all-targets -- -D warnings
+# --lib only: the module shipped to wazero is the library crate.
+#
+# --remap-path-prefix (x2): see build-wasm.sh. rustc bakes the absolute
+# path of every panic site into the binary; remapping the registry and
+# this checkout to fixed placeholders makes the artefact reproducible
+# across machines and $HOMEs, which the gate's byte comparison against
+# the committed copy depends on.
+#
+# The root .cargo/config.toml's rustflags for this target (the getrandom
+# custom-backend cfg) are replaced, not extended, by an env RUSTFLAGS, so
+# they are repeated here. This crate has no getrandom in its graph (see
+# core/pdf/Cargo.toml) and the cfg is inert for it, but keeping the flags
+# identical to build-wasm.sh means the two modules are built under one set
+# of rules rather than two that could drift.
+registry_root="${CARGO_HOME:-$HOME/.cargo}/registry/src"
+workspace_root="$(pwd)"
+export RUSTFLAGS="--cfg getrandom_backend=\"custom\" --remap-path-prefix=${registry_root}=/cargo-registry-src --remap-path-prefix=${workspace_root}=/slides-pdf"
+cargo build --release --manifest-path core/pdf/Cargo.toml --target wasm32-unknown-unknown --locked --lib
+out="core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm"
+[ -f "$out" ] || { echo "build-pdf-wasm: no artifact at $out" >&2; exit 1; }
+echo "$out ($(wc -c < "$out") bytes)"