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
12 files changed,
+1676
−0
core/pdf/.gitignore+1 −0core/pdf/Cargo.lock+833 −0core/pdf/Cargo.toml+44 −0core/pdf/src/abi.rs+183 −0core/pdf/src/lib.rs+180 −0core/pdf/tests/fonts/LiberationSerif-Regular.ttfbinarycore/pdf/tests/no_host_imports.rs+52 −0core/pdf/tests/render.rs+164 −0docs/superpowers/specs/2026-09-07-slides-design.md+1 −0internal/slides/pdfcore/spike_test.go+190 −0internal/slides/pdfcore/testdata/slides_pdf.wasmbinaryscripts/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)" |