oficina / slides Public

swap: krilla-svg replaces the archived svg2pdf behind the same ABI

typst/svg2pdf was archived on 2026-07-10 naming krilla as its successor.
The slice 3 spike had picked svg2pdf without checking that, so core/pdf
was built on a dependency nobody maintains. A second timeboxed spike, in
a scratch copy of the crate so a failure would have cost the repository
nothing, says swap now.

krilla 0.8.2 with krilla-svg 0.8.1 over usvg 0.47, same author and the
same usvg tree underneath. It builds for wasm32-unknown-unknown with no
features turned off at all — krilla-svg has none of its own, and the
resvg, tiny-skia, usvg and fontdb defaults it drags in compile clean,
including tiny-skia's simd — with zero host imports, and it keeps every
property spec §1 ruling 13 asked for: one page per SVG at 1920x1080 pt,
text as subset embedded TrueType (Type0/CIDFontType2, FontFile2,
ToUnicode, a six-letter subset tag), raster images embedded, a bad frame
still a refusal rather than a trap.

The module grows from 3,672,407 to 5,301,002 bytes, which is the whole
cost. Against it, measured through the real host under wazero on one
machine with only the .wasm swapped: 200 pages went from 634/633/637 ms
to 216/220/221 ms, and the PDF from 1,719,771 to 140,444 bytes, because
krilla writes one font subset per document where svg2pdf wrote one per
page. A 200-slide handout that was 1.7 MB of repeated font programs is
140 KB.

The C ABI is byte-identical — slides_pdf_abi_version is still 1, abi.rs
is untouched, and so is internal/slides/pdfcore. Everything above the
module that changed is a test or a comment:

- Tests that grep PDF bytes now take a dictionary entry in either
  spelling. pdf-writer 0.12 wrote "/Type /Page"; 0.15 writes
  "/Type/Page". The object looked for is the same; only the space went.
- export_test.go's little extractor walks page content streams instead
  of form XObjects, and resolves font names through each page's own
  /Resources. krilla writes text straight into the page and calls the
  first font on every page /f0, so a document-wide name map would read
  slide 4's heading in slide 3's face.
- The memory-limit test arms its ceiling at 32 wasm pages rather than
  24: the module's own static memory is 25 pages now.

krilla's XMP metadata packet is turned off. This module has no title,
author or clock to put in one, and what it would carry instead is about
730 bytes of RDF namespace URLs — which breaks the plainer rule the
export tests hold, that a Slides PDF names nothing outside itself. An
identifier is not a link, but a rule that needs that explained is a rule
that stops being checked.

krilla-svg does decode WebP, unlike svg2pdf, so svg.go's
decode-and-re-encode-as-PNG is now belt and braces on the PDF side. It
stays: dropping it changes what the SVG encoder emits and has its own
tests, so it is a change of its own and not a side effect of swapping
the converter. The comments that said otherwise were corrected.

Spec §1 keeps ruling 13, which was true when it was written, and gains
13a beside it; §4.3 gets a dated amendment with these numbers. AGENTS.md,
PRODUCT.md and the review brief follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 035498fe49dcb11fdef87593fe3f48420ff976fe parent 6052b5e
16 files changed, +536 −293
  • AGENTS.md +5 −2
  • CODEX-REVIEW-BRIEF-SPEC.md +1 −1
  • PRODUCT.md +1 −1
  • core/pdf/Cargo.lock +169 −97
  • core/pdf/Cargo.toml +27 −19
  • core/pdf/src/lib.rs +56 −65
  • core/pdf/tests/render.rs +36 −11
  • docs/superpowers/specs/2026-09-07-slides-design.md +38 −8
  • internal/slides/export.go +8 −5
  • internal/slides/export_test.go +129 −50
  • internal/slides/pdfcore/pdfcore.go +9 −4
  • internal/slides/pdfcore/pdfcore_test.go +31 −13
  • internal/slides/pdfcore/testdata/slides_pdf.wasm binary
  • internal/slides/svg/svg.go +17 −15
  • internal/slides/svg/svg_test.go +2 −1
  • internal/slidestest/export_test.go +7 −1

Intent

Human

<task-notification> <task-id>bkbw76mpb</task-id> <tool-use-id>toolu_0124RHJvrGqKpPjt2y7fbstm</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/c8f62d65-b2d1-4a65-b79e-6a8e82e1e5c6/tasks/bkbw76mpb.output</output-file> <status>completed</status> <summary>Background command "Run the full gate on a clean export of the ported tip" completed (exit code 0)</summary> </task-notification>

Human

<task-notification> <task-id>bei72wqy9</task-id> <tool-use-id>toolu_01SK2F7rzDrmEjEMYRy2fFWx</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/c8f62d65-b2d1-4a65-b79e-6a8e82e1e5c6/tasks/bei72wqy9.output</output-file> <status>completed</status> <summary>Background command "Wait for the gate to finish" completed (exit code 0)</summary> </task-notification>

Human

Why not use krilla?

diff --git a/AGENTS.md b/AGENTS.md
index f2ada3c..6987232 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,8 +140,11 @@ the Rust crate. `manifest/` and `gen/` are rastrillo's declarative path,
unused here.
`core/pdf/` is a second Rust crate beside the CRDT core: `slides_pdf.wasm`
-wraps `svg2pdf` and takes one SVG per page plus the Liberation TTFs, and
-writes one PDF with the text as subset, embedded glyphs. `internal/slides/pdfcore`
+wraps `krilla` and `krilla-svg` and takes one SVG per page plus the
+Liberation TTFs, and writes one PDF with the text as subset, embedded
+glyphs. It wrapped `svg2pdf` until 2026-09-09; that repository was
+archived on 2026-07-10 naming krilla as its successor, and the swap kept
+the ABI byte-identical (spec §1 ruling 13). `internal/slides/pdfcore`
hosts it under wazero with the same containment as `core` but a simpler
shape — a fresh module instance per `Render`, discarded afterwards, under
a sixty-second job deadline rather than the CRDT host's one-second call
diff --git a/CODEX-REVIEW-BRIEF-SPEC.md b/CODEX-REVIEW-BRIEF-SPEC.md
index d91e61c..77327a5 100644
--- a/CODEX-REVIEW-BRIEF-SPEC.md
+++ b/CODEX-REVIEW-BRIEF-SPEC.md
@@ -67,7 +67,7 @@ half and the op vocabulary that must mirror `core/src/ops.rs`).
CSRF because a bearer caller sends no browser headers. Sound?
7. **The export path** (§4.2, §4.3, ruling 13): a Go layout engine
measuring with embedded Liberation faces, an SVG per slide, and
- `svg2pdf` in a wasm module under wazero. With no object store bound the
+ `krilla` in a wasm module under wazero. With no object store bound the
finished PDF is held in memory for an hour, capped. Where does this
break first?
8. **What the spec does not say** that a second reader would expect a
diff --git a/PRODUCT.md b/PRODUCT.md
index 9420a11..185bb01 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -164,7 +164,7 @@ that hold it to the Rust one.
which measures a slide into a paint list with the twelve Liberation faces
embedded in the binary and served to every slide surface; the SVG
serialiser over that paint list, and a thumbnail route on all four
-capabilities; and PDF export — `svg2pdf` compiled to wasm32 and hosted
+capabilities; and PDF export — `krilla` compiled to wasm32 and hosted
under wazero beside the CRDT core (`core/pdf`,
`internal/slides/pdfcore`), driven by a rastrillo job whose status page
polls itself and works with scripts off: one 16:9 page per slide, the
diff --git a/core/pdf/Cargo.lock b/core/pdf/Cargo.lock
index b0764e1..257cab3 100644
--- a/core/pdf/Cargo.lock
+++ b/core/pdf/Cargo.lock
@@ -34,15 +34,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
-version = "1.3.2"
+version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
-name = "bitflags"
-version = "2.13.1"
+name = "bumpalo"
+version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
@@ -61,7 +61,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
]
[[package]]
@@ -146,6 +146,9 @@ name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+dependencies = [
+ "num-traits",
+]
[[package]]
name = "foldhash"
@@ -168,7 +171,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
- "roxmltree",
+ "roxmltree 0.20.0",
]
[[package]]
@@ -207,27 +210,20 @@ dependencies = [
]
[[package]]
-name = "image"
-version = "0.25.10"
+name = "image-webp"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
- "bytemuck",
"byteorder-lite",
- "color_quant",
- "gif",
- "moxcms",
- "num-traits",
- "png 0.18.1",
- "zune-core",
- "zune-jpeg",
+ "quick-error",
]
[[package]]
name = "imagesize"
-version = "0.13.0"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
+checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indexmap"
@@ -248,14 +244,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
-name = "kurbo"
-version = "0.11.3"
+name = "krilla"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
dependencies = [
- "arrayvec",
- "euclid",
+ "base64",
+ "bumpalo",
+ "flate2",
+ "float-cmp",
+ "gif",
+ "image-webp",
+ "imagesize",
+ "indexmap",
+ "pdf-writer",
+ "png",
+ "rustc-hash",
+ "rustybuzz",
+ "siphasher",
+ "skrifa",
"smallvec",
+ "subsetter",
+ "tiny-skia-path",
+ "xmp-writer",
+ "yoke",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "krilla-svg"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16"
+dependencies = [
+ "flate2",
+ "fontdb",
+ "krilla",
+ "resvg",
+ "skrifa",
+ "smallvec",
+ "tiny-skia",
+ "usvg",
]
[[package]]
@@ -323,16 +352,6 @@ dependencies = [
"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"
@@ -342,19 +361,13 @@ 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"
+version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5df03c7d216de06f93f398ef06f1385a60f2c597bb96f8195c8d98e08a26b1d5"
+checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
dependencies = [
- "bitflags 2.13.1",
+ "bitflags",
"itoa",
"memchr",
"ryu",
@@ -366,26 +379,13 @@ 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",
+ "bitflags",
"crc32fast",
"fdeflate",
"flate2",
@@ -411,10 +411,10 @@ dependencies = [
]
[[package]]
-name = "pxfm"
-version = "0.1.30"
+name = "quick-error"
+version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
@@ -437,16 +437,19 @@ dependencies = [
[[package]]
name = "resvg"
-version = "0.45.1"
+version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
+checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
+ "gif",
+ "image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
+ "zune-jpeg",
]
[[package]]
@@ -464,6 +467,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "roxmltree"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -476,7 +488,7 @@ version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
- "bitflags 2.13.1",
+ "bitflags",
"bytemuck",
"core_maths",
"log",
@@ -526,7 +538,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.5",
]
[[package]]
@@ -566,8 +578,8 @@ version = "0.1.0"
dependencies = [
"flate2",
"fontdb",
- "pdf-writer",
- "svg2pdf",
+ "krilla",
+ "krilla-svg",
"usvg",
"wasmparser",
]
@@ -587,6 +599,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -602,39 +620,31 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
dependencies = [
- "kurbo 0.13.1",
+ "kurbo",
"rustc-hash",
"skrifa",
"write-fonts",
]
[[package]]
-name = "svg2pdf"
-version = "0.13.0"
+name = "svgtypes"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22"
+checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
- "fontdb",
- "image",
- "log",
- "miniz_oxide 0.8.9",
- "once_cell",
- "pdf-writer",
- "resvg",
+ "kurbo",
"siphasher",
- "subsetter",
- "ttf-parser",
- "usvg",
]
[[package]]
-name = "svgtypes"
-version = "0.15.3"
+name = "syn"
+version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
- "kurbo 0.11.3",
- "siphasher",
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
]
[[package]]
@@ -648,26 +658,37 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "tiny-skia"
-version = "0.11.4"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
- "png 0.17.16",
+ "png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
-version = "0.11.4"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
@@ -742,25 +763,26 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]]
name = "usvg"
-version = "0.45.1"
+version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
+checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
- "kurbo 0.11.3",
+ "kurbo",
"log",
"pico-args",
- "roxmltree",
+ "roxmltree 0.21.1",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
+ "ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -779,7 +801,7 @@ version = "0.258.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313"
dependencies = [
- "bitflags 2.13.1",
+ "bitflags",
"hashbrown",
"indexmap",
"semver",
@@ -800,7 +822,7 @@ checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
dependencies = [
"font-types",
"indexmap",
- "kurbo 0.13.1",
+ "kurbo",
"log",
"read-fonts",
]
@@ -811,6 +833,56 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
+[[package]]
+name = "xmp-writer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.7"
diff --git a/core/pdf/Cargo.toml b/core/pdf/Cargo.toml
index 8b38095..aa3dd64 100644
--- a/core/pdf/Cargo.toml
+++ b/core/pdf/Cargo.toml
@@ -7,26 +7,34 @@ edition = "2021"
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"] }
+# krilla is the PDF writer; krilla-svg draws a usvg tree onto one of its
+# surfaces. It is the successor to svg2pdf, which this crate used until
+# 2026-09-09 and whose repository (typst/svg2pdf) was archived on
+# 2026-07-10 pointing here. `raster-images` is krilla's decoder set for
+# `<image>` hrefs (PNG, JPEG, GIF, WebP); `simple-text` (rustybuzz
+# shaping for krilla's own text API) is off because every glyph this
+# crate draws is already positioned by usvg, and krilla-svg calls the
+# glyph API directly.
+#
+# krilla-svg has no Cargo features of its own: its usvg, resvg,
+# tiny-skia, fontdb, skrifa, flate2 and smallvec dependencies are all
+# unconditional and all take default features, so there is nothing to
+# turn off from here. That includes usvg's and fontdb's filesystem
+# features (fs, memmap, fontconfig), which are pulled in whatever this
+# manifest says because Cargo features are additive across the graph.
+# It builds for wasm32-unknown-unknown regardless: memmap2 and
+# fontconfig-parser carry stubs for non-unix, non-windows targets, and
+# nothing here ever calls `load_system_fonts` — faces come only from
+# `Database::load_font_data`. tiny-skia's default `simd` feature also
+# builds clean for wasm32 (it falls back to scalar code where there is
+# no `std::arch` to use).
+krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
+krilla-svg = "=0.8.1"
+# Named directly so this crate can build a fontdb::Database from bytes
+# handed over the ABI and parse the SVG itself. The versions must be the
+# ones krilla-svg resolves, or `usvg::Tree` would be two distinct types.
+usvg = { version = "=0.47.0", 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
diff --git a/core/pdf/src/lib.rs b/core/pdf/src/lib.rs
index 6f28bd7..896c491 100644
--- a/core/pdf/src/lib.rs
+++ b/core/pdf/src/lib.rs
@@ -1,10 +1,18 @@
//! 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.
+//! run under wazero beside the CRDT core (`core/`). It wraps `krilla` (a
+//! PDF writer) and `krilla-svg` (its SVG converter, over `usvg`) with a
+//! `fontdb` that holds only the faces the host hands over — the Liberation
+//! TTFs the layout engine measured with — so the PDF's text is real glyphs
+//! from those exact fonts, subset and embedded, never paths and never a
+//! system font.
+//!
+//! Until 2026-09-09 this was `svg2pdf`, whose repository (typst/svg2pdf)
+//! was archived on 2026-07-10 naming krilla as its successor. The two are
+//! by the same author over the same usvg tree, so the swap kept every
+//! property the module had and improved two: a font subset is written
+//! once per document rather than once per page (200 pages went from 1.72
+//! MB to 140 KB) and the render is about three times faster. The module
+//! grew from 3.50 to 5.06 MiB. See spec §1 ruling 13.
//!
//! Each SVG becomes one page whose size is the SVG's own `width`/`height`
//! in user units read as PDF points (1 px = 1 pt): a 1920 by 1080 slide is
@@ -31,16 +39,18 @@
pub mod abi;
-use std::collections::HashMap;
use std::sync::Arc;
-use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, TextStr};
-use svg2pdf::ConversionOptions;
+use krilla::geom::Size;
+use krilla::page::PageSettings;
+use krilla::{Document, SerializeSettings};
+use krilla_svg::{SurfaceExt, SvgSettings};
/// Returns the ABI version this module exports. The Go host checks this on
/// load and refuses a module it does not understand. Version 1 is the
/// surface `abi.rs` exports today: alloc/free, render, and the out/err read
-/// pairs.
+/// pairs. The krilla swap changed nothing here, which is the point: the
+/// host and everything above it were untouched by it.
#[no_mangle]
pub extern "C" fn slides_pdf_abi_version() -> u32 {
1
@@ -82,77 +92,58 @@ pub fn render(svgs: &[&[u8]], fonts: &[&[u8]]) -> Result<Vec<u8>, RenderError> {
}
// 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.
+ // (mm, in, pt) inside the SVG; slide SVGs are in px. The page is
+ // given the tree's own size below, so the px-to-pt mapping is 1:1.
let options = usvg::Options {
fontdb: Arc::new(font_database(fonts)),
font_family: "Liberation Sans".to_string(),
..Default::default()
};
- let conversion = ConversionOptions {
- compress: true,
- raster_scale: 1.5,
+ // `embed_text` is what makes the text real glyphs rather than filled
+ // outlines; it is krilla-svg's default and named here because it is
+ // the property spec §4.3 asks for, not an incidental setting.
+ // `filter_scale` is the rasterisation factor for SVG filter effects,
+ // which slides emit none of.
+ let svg_settings = SvgSettings {
embed_text: true,
- pdfa: false,
+ filter_scale: 1.5,
};
- 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());
+ // XMP off. krilla writes an XMP packet by default, and this module
+ // has nothing to put in one: no title, no author, no date (there is
+ // no clock here). What it would carry instead is half a kilobyte of
+ // RDF namespace URLs, which breaks the plainer property `export.go`
+ // and its tests hold — a Slides PDF names nothing outside itself, so
+ // grepping the bytes for "http" finds nothing. An identifier is not
+ // a link, but a rule that needs that distinction explained is a rule
+ // that stops being checked.
+ let settings = SerializeSettings {
+ xmp_metadata: false,
+ ..Default::default()
+ };
+ let mut document = Document::new_with(settings);
for (i, svg) in svgs.iter().enumerate() {
let tree = usvg::Tree::from_data(svg, &options)
.map_err(|e| RenderError(format!("page {}: svg: {e}", i + 1)))?;
- 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();
+ // `Size::from_wh` rejects a non-finite or non-positive size,
+ // which usvg will not produce from a parsed tree, but a page of
+ // zero width is not something to write out either way.
+ let size = Size::from_wh(tree.size().width(), tree.size().height())
+ .ok_or_else(|| RenderError(format!("page {}: page size is not drawable", i + 1)))?;
+ let mut page = document.start_page_with(PageSettings::new(size));
+ let mut surface = page.surface();
+ surface.draw_svg(&tree, size, svg_settings);
+ surface.finish();
page.finish();
-
- // 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())
+ // krilla writes the whole document at the end, which is what lets it
+ // share one subset of each face across every page.
+ document
+ .finish()
+ .map_err(|e| RenderError(format!("krilla: {e}")))
}
/// Splits a length-prefixed frame sequence: each item is a 4-byte
diff --git a/core/pdf/tests/render.rs b/core/pdf/tests/render.rs
index 3b6f9ce..67d0410 100644
--- a/core/pdf/tests/render.rs
+++ b/core/pdf/tests/render.rs
@@ -1,5 +1,10 @@
//! Native proof that the module produces a PDF with the text as embedded
//! glyphs from the font it was given, not as paths.
+//!
+//! The assertions grep the PDF's bytes rather than parsing it, so they
+//! tolerate both spellings of a dictionary entry: pdf-writer 0.12 (which
+//! svg2pdf used) wrote `/Type /Page`, and 0.15 (which krilla uses) writes
+//! `/Type/Page`. The object is the property under test; the space is not.
use std::io::Read;
@@ -73,17 +78,37 @@ fn count(hay: &[u8], needle: &[u8]) -> usize {
hay.windows(needle.len()).filter(|w| *w == needle).count()
}
+/// Both spellings of a `/Key /Value` entry. See the module comment.
+fn count_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> usize {
+ count(hay, spaced) + count(hay, tight)
+}
+
+fn find_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> bool {
+ find(hay, spaced).is_some() || find(hay, tight).is_some()
+}
+
+/// Page objects, less the one page-tree node whose `/Type /Pages` also
+/// matches the `/Type /Page` prefix.
+fn page_count(pdf: &[u8]) -> usize {
+ count_either(pdf, b"/Type /Page", b"/Type/Page")
+ - count_either(pdf, b"/Type /Pages", b"/Type/Pages")
+}
+
#[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");
+ let pages = page_count(&pdf);
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(),
+ find_either(
+ &pdf,
+ b"/MediaBox [0 0 1920 1080]",
+ b"/MediaBox[0 0 1920 1080]"
+ ),
"page is 1920x1080 pt"
);
@@ -92,7 +117,10 @@ fn renders_text_as_embedded_glyphs() {
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_either(&pdf, b"/Type /Font", b"/Type/Font"),
+ "no /Font dictionary"
+ );
assert!(
find(&pdf, b"LiberationSerif").is_some(),
"font name not Liberation Serif"
@@ -100,10 +128,10 @@ fn renders_text_as_embedded_glyphs() {
// 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();
+ 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(),
+ find(&plain, b"BT").is_some() && find(&plain, b"Tf").is_some(),
"no BT/Tf"
);
@@ -118,7 +146,7 @@ fn renders_text_as_embedded_glyphs() {
#[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");
+ let pages = page_count(&pdf);
assert_eq!(pages, 2);
assert!(find(&pdf, b"/Count 2").is_some());
}
@@ -169,10 +197,7 @@ fn text_with_no_fonts_at_all_still_renders_the_shapes() {
// warning, never substitutes and never errors. The shapes are still
// a page.
let pdf = slides_pdf::render(&[SVG.as_bytes()], &[]).expect("render");
- assert_eq!(
- count(&pdf, b"/Type /Page") - count(&pdf, b"/Type /Pages"),
- 1
- );
+ assert_eq!(page_count(&pdf), 1);
assert!(find(&pdf, b"/FontFile2").is_none());
}
@@ -187,7 +212,7 @@ fn raster_image_is_embedded() {
);
let pdf = slides_pdf::render(&[svg.as_bytes()], &[FONT]).expect("render");
assert!(
- find(&pdf, b"/Subtype /Image").is_some(),
+ find_either(&pdf, b"/Subtype /Image", b"/Subtype/Image"),
"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 d9af224..29267e1 100644
--- a/docs/superpowers/specs/2026-09-07-slides-design.md
+++ b/docs/superpowers/specs/2026-09-07-slides-design.md
@@ -82,6 +82,7 @@ calls.
| 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 |
+| 13a | **`svg2pdf` replaced by `krilla` 0.8.2 + `krilla-svg` 0.8.1 behind the same ABI.** typst/svg2pdf was archived 2026-07-10 naming krilla — same author, same usvg tree — as its successor, so the module was wrapping an unmaintained dependency the spike had not checked. The swap builds for wasm32-unknown-unknown with **no feature flags turned off at all** (krilla-svg has none, and its resvg/tiny-skia/usvg/fontdb defaults compile clean for wasm32), **0 host imports**, and keeps every property ruling 13 asked for: one page per SVG, 1920×1080 pt, Type0/CIDFontType2, FontFile2, ToUnicode, subset tags, embedded raster images. Module 3,672,407 → **5,301,002 bytes** (3.50 → 5.06 MiB). 200 pages through the Go host: 642 ms → **220 ms**, and the PDF 1,719,771 → **140,444 bytes**, because krilla writes one subset per document where svg2pdf wrote one per page. `slides_pdf_abi_version` is still 1 and `internal/slides/pdfcore` was not touched. | Staying on an archived dependency; or waiting for a forced upgrade with a CVE attached to it. Also rejected: keeping krilla's default XMP packet, whose RDF namespace URLs would put `http` in a PDF that names nothing outside itself. | Session, 2026-09-09, krilla spike |
| 14 | Uploads are **staged then promoted**: `POST /uploads` mints a staging key of the app's own and signs a PUT for that alone; registration resolves an upload receipt (this deck, this actor, unspent, under two hours), reads the bytes, and puts them back at their own SHA-256. No PUT is ever signed for an address anything is served from, and a ref alone registers nothing. | Signing a PUT for the client's declared hash and trusting the object afterwards — which made a verified picture overwritable and made knowing a ref permission to read it. Also rejected: an authorised cross-deck copy, which nothing asks for yet. | Session, 2026-09-08, from codex 1 and codex 2 |
| 15 | **Presenting is every member's; minting a share link is the owner's**, and an audience follows through the **share link**, never the session sqid. The sqid is a projector address: unrevocable, so it goes only to whoever is presenting. | Making presenting owner-only (reading a deck to a room is reading a deck), or leaving followers on `/p/{session}` where the link's revocation could not reach them. | Session, 2026-09-08, from codex 5 |
| 16 | **Admission is rechecked wherever a principal is resolved** — the session's subject in `currentUser`, the operator's address in the bearer gate — and fails closed. Removing an address revokes its sessions on every device and ends its live talks; its share links keep working, because a share link is the deck's and the deck is the team's. | The allowlist gating only the minting of a session, which left a removed person everything they already held, agents included. | Session, 2026-09-08, from codex 6 |
@@ -564,14 +565,16 @@ restriction.
### 4.3 PDF
`core/` (Rust) gains a second module beside the CRDT: `slides_pdf`,
-wrapping `svg2pdf` with `usvg` and `fontdb` fed the same embedded fonts,
-compiled to wasm32 and run under wazero like the CRDT core. Input: one
+wrapping `krilla` and `krilla-svg` with `usvg` and `fontdb` fed the same
+embedded fonts, compiled to wasm32 and run under wazero like the CRDT
+core. (It wrapped `svg2pdf` from slice 3's Task 1 spike until
+2026-09-09; see the amendment at the end of this section.) Input: one
SVG per slide plus the fonts; output: one PDF, 1920 by 1080 points per
page, with the speaker notes as an optional second page per slide
("with notes" export). Images are embedded at their stored resolution
capped at 2000 px on the long edge.
-If `svg2pdf` under wasm32 proves unworkable in the plan's spike, the
+If SVG-to-PDF in Rust under wasm32 proves unworkable in the plan's spike, the
fallback recorded now is a Go PDF writer over the paint list directly,
reusing Docs' font subsetting (`oficina/docs` `internal/pdf`) by
extraction to a shared module, not by copy.
@@ -602,8 +605,8 @@ section said differently:
hour** and served from the instance, capped at 50 MiB per deck and
200 MiB per instance; with a bucket the job uploads it and the
download route 302s to the presigned GET. Both paths are one handler.
- The spike ruled `svg2pdf` under wasm32 viable, so the fallback Go PDF
- writer above was not built.
+ The spike ruled SVG-to-PDF in Rust under wasm32 viable, so the
+ fallback Go PDF writer above was not built.
- **The status page and the file agree about the hour.** rastrillo/jobs
forgets a finished job ten minutes after it ends; the bytes are
answerable for the hour the page promised. So the status page, its
@@ -615,9 +618,36 @@ section said differently:
make the two agree and takes away a download that was promised.
A WebP image is re-encoded as a PNG on its way into a page
-(`golang.org/x/image/webp`), because neither a browser rendering an
-`<img src>` SVG nor `svg2pdf` decodes one, and WebP is one of the four
-types §2.7 admits.
+(`golang.org/x/image/webp`), because a browser rendering an `<img src>`
+SVG fetches nothing and WebP is one of the four types §2.7 admits.
+`krilla-svg` does decode WebP directly, so since the swap below this is
+belt and braces on the PDF side; it is kept because dropping it changes
+what the SVG encoder emits.
+
+**Amended 2026-09-09: the converter is krilla, not svg2pdf.**
+`typst/svg2pdf` was archived on 2026-07-10 naming krilla as its
+successor, which the original spike had not checked, so the module was
+built on a dependency nobody was maintaining. A second timeboxed spike
+swapped it: `krilla` 0.8.2 with `krilla-svg` 0.8.1 over `usvg` 0.47 and
+the same `fontdb` fed from linear memory. It builds for
+wasm32-unknown-unknown with no features disabled — krilla-svg has none,
+and the resvg and tiny-skia defaults it drags in compile clean — with
+zero host imports, and every property ruling 13 asked for holds: one
+page per SVG at 1920×1080 pt, text as subset embedded TrueType
+(Type0/CIDFontType2, FontFile2, ToUnicode), raster images embedded, and
+a frame error still a refusal rather than a trap. The module grew from
+3,672,407 to 5,301,002 bytes. Against that, a 200-page export through
+the Go host went from 642 ms to 220 ms and from 1,719,771 to 140,444
+bytes, because krilla writes one font subset per document where svg2pdf
+wrote one per page — a handout that used to be 1.7 MB of repeated font
+programs is now 140 KB. krilla's XMP metadata packet is turned off:
+this module has no title, author or clock to put in one, and what it
+would carry instead is RDF namespace URLs, which would break the plainer
+rule that a Slides PDF names nothing outside itself. The C ABI
+(`slides_pdf_abi_version` is still 1) and `internal/slides/pdfcore` did
+not change; what did change above the module is the tests, which grep
+PDF bytes and now take a dictionary entry in either spelling, because
+pdf-writer 0.12 wrote `/Type /Page` and 0.15 writes `/Type/Page`.
---
diff --git a/internal/slides/export.go b/internal/slides/export.go
index 978ee16..2c9d19a 100644
--- a/internal/slides/export.go
+++ b/internal/slides/export.go
@@ -44,11 +44,14 @@ import (
// whole render on it (core/pdf/src/lib.rs).
_ "image/gif"
- // WebP is one of the four types the upload path admits (§2.7) and
- // nothing downstream draws one: registering the decoder is what
- // sends it down the decode-and-re-encode path below, where it comes
- // out as the PNG svg2pdf can read. Without it a picture somebody
- // uploaded from a phone was simply missing from their handout.
+ // WebP is one of the four types the upload path admits (§2.7):
+ // registering the decoder is what sends it down the
+ // decode-and-re-encode path below, where it comes out as a PNG.
+ // Without it a picture somebody uploaded from a phone was simply
+ // missing from their handout. (krilla-svg does decode WebP, unlike
+ // the svg2pdf it replaced, so the re-encode is belt and braces on
+ // the PDF side now; dropping it is its own change, with its own
+ // tests, not a side effect of swapping the converter.)
_ "golang.org/x/image/webp"
"amadan.net/oficina/slides/internal/slides/deck"
diff --git a/internal/slides/export_test.go b/internal/slides/export_test.go
index 6fc08aa..c82c39a 100644
--- a/internal/slides/export_test.go
+++ b/internal/slides/export_test.go
@@ -66,8 +66,18 @@ func sampleDeckForExport(t *testing.T) *deck.Deck {
return d
}
+// The assertions here grep the PDF's bytes rather than parsing it, so
+// they take a dictionary entry in either spelling: pdf-writer 0.12
+// (which svg2pdf used) wrote "/Type /Page", and 0.15 (which krilla
+// uses) writes "/Type/Page". The object is the property under test, not
+// the space between the key and the value.
+func pdfCount(pdf []byte, entry string) int {
+ return bytes.Count(pdf, []byte(entry)) +
+ bytes.Count(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
+}
+
func pdfPages(pdf []byte) int {
- return bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
+ return pdfCount(pdf, "/Type /Page") - pdfCount(pdf, "/Type /Pages")
}
// exportRuntime is the compiled PDF module these tests render through,
@@ -128,9 +138,9 @@ func TestExportSampleDeck(t *testing.T) {
// Every slide's heading is in the PDF as glyph runs: the content
// streams' Tj strings, mapped back through each font's ToUnicode
- // CMap. Spaces are dropped on both sides because svg2pdf positions
- // each glyph on its own, so a word gap is a coordinate rather than
- // a character.
+ // CMap. Spaces are dropped on both sides because usvg positions each
+ // glyph on its own, so a word gap is a coordinate rather than a
+ // character.
text := strings.ReplaceAll(pdfText(t, pdf), " ", "")
var headings []string
for _, s := range d.Slides {
@@ -272,7 +282,7 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) {
"junk": {[]byte("RIFF\x00\x00\x00\x00WEBPnot really"), "image/webp"},
"huge": {make([]byte, maxExportAsset+1), "image/png"},
// A real WebP — one of the four types the upload path admits.
- // svg2pdf cannot read one, so it is decoded and re-encoded on
+ // the SVG encoder cannot inline one, so it is decoded and re-encoded on
// the way in rather than dropped (golang.org/x/image/webp).
"webp": {webpBytes(t), "image/webp"},
}
@@ -307,7 +317,7 @@ func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) {
// Three pictures made it in: the small one as it was stored, the
// big one scaled to 2000 on its long edge, and the WebP re-encoded
// as a PNG at its own 4 by 3.
- if got := bytes.Count(pdf, []byte("/Subtype /Image")); got != 3 {
+ if got := pdfCount(pdf, "/Subtype /Image"); got != 3 {
t.Errorf("image XObjects = %d, want 3 (small, big and the WebP)", got)
}
for _, want := range []string{"/Width 300", "/Height 200", "/Width 2000", "/Height 1000", "/Width 4", "/Height 3"} {
@@ -395,18 +405,32 @@ func TestDownscaleIsABoxFilterThatKeepsTheAspect(t *testing.T) {
//
// pdfText reads the words back out of a PDF the module wrote: every
// FlateDecode stream inflated, every Type0 font's ToUnicode CMap
-// parsed, and every form XObject's content stream walked for Tf and Tj,
-// mapping each two-byte CID through the current font's CMap. It is
-// enough to prove the text is there as glyphs with a Unicode mapping,
-// which is what "select and copy from the PDF" needs; it is not an
-// extractor anyone should use for anything else.
-
-var objRE = regexp.MustCompile(`(?m)^(\d+) 0 obj\n`)
-
-func pdfText(t *testing.T, pdf []byte) string {
- t.Helper()
- plain := inflateStreams(t, pdf)
+// parsed, and every page's content stream walked for Tf and Tj, mapping
+// each two-byte CID through the font that Tf selected. It is enough to
+// prove the text is there as glyphs with a Unicode mapping, which is
+// what "select and copy from the PDF" needs; it is not an extractor
+// anyone should use for anything else.
+//
+// The font resource names are page-local — krilla calls the first font
+// on every page /f0 — so the name is resolved through that page's own
+// /Resources dictionary and never through a document-wide map, which
+// would read slide 4's heading in slide 3's face.
+
+var (
+ objRE = regexp.MustCompile(`(?m)^(\d+) 0 obj\n`)
+ toUnicodeRE = regexp.MustCompile(`/ToUnicode ?(\d+) 0 R`)
+ contentsRE = regexp.MustCompile(`/Contents ?(\d+) 0 R`)
+ resourcesRE = regexp.MustCompile(`/Resources ?(\d+) 0 R`)
+ kidsRE = regexp.MustCompile(`/Kids\s*\[([^\]]*)\]`)
+ refRE = regexp.MustCompile(`(\d+) 0 R`)
+ fontRefRE = regexp.MustCompile(`/([A-Za-z]\w*) ?(\d+) 0 R`)
+ // One pass over a content stream in operator order: a font
+ // selection, or a string shown. Anything else is skipped.
+ textOpRE = regexp.MustCompile(`(?s)/([A-Za-z]\w*) [\d.]+ Tf|\(((?:\\.|[^()\\])*)\)\s*Tj`)
+)
+// pdfObjects splits an inflated PDF into its numbered objects.
+func pdfObjects(plain []byte) map[int][]byte {
objects := map[int][]byte{}
locs := objRE.FindAllSubmatchIndex(plain, -1)
for i, loc := range locs {
@@ -417,12 +441,29 @@ func pdfText(t *testing.T, pdf []byte) string {
}
objects[n] = plain[loc[1]:end]
}
+ return objects
+}
+
+// streamBody is the bytes between "stream" and "endstream", which
+// inflateStreams has already made plain.
+func streamBody(obj []byte) ([]byte, bool) {
+ i := bytes.Index(obj, []byte("stream\n"))
+ j := bytes.LastIndex(obj, []byte("endstream"))
+ if i < 0 || j < i {
+ return nil, false
+ }
+ return obj[i+len("stream\n") : j], true
+}
+
+func pdfText(t *testing.T, pdf []byte) string {
+ t.Helper()
+ objects := pdfObjects(inflateStreams(t, pdf))
// Font object number -> CID -> text.
cmaps := map[int]map[uint16]string{}
- toUnicodeRE := regexp.MustCompile(`/ToUnicode (\d+) 0 R`)
for n, body := range objects {
- if !bytes.Contains(body, []byte("/Subtype /Type0")) {
+ if !bytes.Contains(body, []byte("/Subtype /Type0")) &&
+ !bytes.Contains(body, []byte("/Subtype/Type0")) {
continue
}
m := toUnicodeRE.FindSubmatch(body)
@@ -433,43 +474,45 @@ func pdfText(t *testing.T, pdf []byte) string {
cmaps[n] = parseCMap(t, objects[ref])
}
- var forms []int
- for n, body := range objects {
- if bytes.Contains(body, []byte("/Subtype /Form")) {
- forms = append(forms, n)
- }
- }
- sort.Ints(forms)
-
- fontRefRE := regexp.MustCompile(`/(fo\d+) (\d+) 0 R`)
var out strings.Builder
- for _, n := range forms {
- body := objects[n]
+ for _, n := range pdfPageOrder(objects) {
+ page := objects[n]
+
+ // This page's own font names.
fontsByName := map[string]map[uint16]string{}
- for _, m := range fontRefRE.FindAllSubmatch(body, -1) {
- ref, _ := strconv.Atoi(string(m[2]))
- fontsByName[string(m[1])] = cmaps[ref]
+ if m := resourcesRE.FindSubmatch(page); m != nil {
+ ref, _ := strconv.Atoi(string(m[1]))
+ res := objects[ref]
+ if i := bytes.Index(res, []byte("/Font<<")); i >= 0 {
+ j := bytes.Index(res[i:], []byte(">>"))
+ for _, f := range fontRefRE.FindAllSubmatch(res[i:i+j], -1) {
+ fref, _ := strconv.Atoi(string(f[2]))
+ fontsByName[string(f[1])] = cmaps[fref]
+ }
+ }
+ }
+
+ m := contentsRE.FindSubmatch(page)
+ if m == nil {
+ continue
}
- i := bytes.Index(body, []byte("stream\n"))
- j := bytes.LastIndex(body, []byte("endstream"))
- if i < 0 || j < i {
+ ref, _ := strconv.Atoi(string(m[1]))
+ content, ok := streamBody(objects[ref])
+ if !ok {
continue
}
- content := body[i+len("stream\n") : j]
+
var cur map[uint16]string
- for _, line := range bytes.Split(content, []byte("\n")) {
- s := string(line)
- switch {
- case strings.HasSuffix(s, " Tf"):
- name := strings.TrimPrefix(strings.Fields(s)[0], "/")
- cur = fontsByName[name]
- case strings.HasSuffix(s, " Tj") && strings.HasPrefix(s, "("):
- raw := pdfLiteral(s[1:strings.LastIndex(s, ")")])
- for k := 0; k+1 < len(raw); k += 2 {
- cid := uint16(raw[k])<<8 | uint16(raw[k+1])
- if cur != nil {
- out.WriteString(cur[cid])
- }
+ for _, op := range textOpRE.FindAllSubmatch(content, -1) {
+ if len(op[1]) > 0 {
+ cur = fontsByName[string(op[1])]
+ continue
+ }
+ raw := pdfLiteral(string(op[2]))
+ for k := 0; k+1 < len(raw); k += 2 {
+ cid := uint16(raw[k])<<8 | uint16(raw[k+1])
+ if cur != nil {
+ out.WriteString(cur[cid])
}
}
}
@@ -478,6 +521,42 @@ func pdfText(t *testing.T, pdf []byte) string {
return out.String()
}
+// pdfPageOrder is the page objects in the order the page tree lists
+// them, so extracted text comes out slide by slide rather than in
+// whatever order the objects happen to be numbered.
+func pdfPageOrder(objects map[int][]byte) []int {
+ var tree []byte
+ for _, body := range objects {
+ if bytes.Contains(body, []byte("/Type /Pages")) ||
+ bytes.Contains(body, []byte("/Type/Pages")) {
+ tree = body
+ break
+ }
+ }
+ if m := kidsRE.FindSubmatch(tree); m != nil {
+ var order []int
+ for _, k := range refRE.FindAllSubmatch(m[1], -1) {
+ n, _ := strconv.Atoi(string(k[1]))
+ order = append(order, n)
+ }
+ return order
+ }
+ // No page tree found: fall back to every page object, in number
+ // order, which is at least deterministic.
+ var order []int
+ for n, body := range objects {
+ isPage := bytes.Contains(body, []byte("/Type /Page")) ||
+ bytes.Contains(body, []byte("/Type/Page"))
+ isTree := bytes.Contains(body, []byte("/Type /Pages")) ||
+ bytes.Contains(body, []byte("/Type/Pages"))
+ if isPage && !isTree {
+ order = append(order, n)
+ }
+ }
+ sort.Ints(order)
+ return order
+}
+
// parseCMap reads bfchar and bfrange entries out of a ToUnicode CMap.
func parseCMap(t *testing.T, body []byte) map[uint16]string {
t.Helper()
diff --git a/internal/slides/pdfcore/pdfcore.go b/internal/slides/pdfcore/pdfcore.go
index 635a386..bc0d45c 100644
--- a/internal/slides/pdfcore/pdfcore.go
+++ b/internal/slides/pdfcore/pdfcore.go
@@ -13,7 +13,7 @@
//
// # Containment
//
-// usvg and svg2pdf are parsers over bytes a deck author controls (an
+// usvg and krilla-svg are parsers over bytes a deck author controls (an
// inlined image, a text run), and the module is built with
// `panic = "abort"`, so a bug in either is a wasm trap rather than an
// error. wazero turns a trap into an ordinary error from Call, the
@@ -25,6 +25,10 @@
// is terminated by wazero itself rather than left running after the
// caller has given up on it.
//
+// The module wrapped svg2pdf until 2026-09-09 and wraps krilla and
+// krilla-svg since; the ABI and this host did not change with it. See
+// spec §1 ruling 13.
+//
// A clean refusal — a malformed SVG, an empty page list — comes back
// from the module as a negative code with a message, wrapped here in
// RejectedError so a caller can tell "your deck has a problem" from
@@ -80,9 +84,10 @@ const maxFramesBytes = 1 << 30
// from Call which Render wraps like any other failure.
//
// Half a gibibyte rather than something tighter: the measured ceiling
-// is a 200-page render with inlined 2000 px images, and svg2pdf holds
-// every page's tree while it writes. A render that needs more than
-// this is a deck nobody should be waiting on anyway.
+// is a 200-page render with inlined 2000 px images, and krilla holds the
+// whole document until it writes it out (which is what lets it share one
+// font subset across every page). A render that needs more than this is
+// a deck nobody should be waiting on anyway.
const defaultMemoryPages = 8192
// Option configures a Runtime. See WithGuestDeadline.
diff --git a/internal/slides/pdfcore/pdfcore_test.go b/internal/slides/pdfcore/pdfcore_test.go
index cb808bd..00d51f1 100644
--- a/internal/slides/pdfcore/pdfcore_test.go
+++ b/internal/slides/pdfcore/pdfcore_test.go
@@ -57,8 +57,23 @@ func runtime(t *testing.T) *pdfcore.Runtime {
return runtimeOnce.rt
}
+// pageCount and its neighbours grep the PDF's bytes rather than parsing
+// it, so they take a dictionary entry in either spelling: pdf-writer 0.12
+// (which svg2pdf used) wrote "/Type /Page", and 0.15 (which krilla uses)
+// writes "/Type/Page". The object is the property under test, not the
+// space between the key and the value.
+func has(pdf []byte, entry string) bool {
+ return bytes.Contains(pdf, []byte(entry)) ||
+ bytes.Contains(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
+}
+
+func countEither(pdf []byte, entry string) int {
+ return bytes.Count(pdf, []byte(entry)) +
+ bytes.Count(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
+}
+
func pageCount(pdf []byte) int {
- return bytes.Count(pdf, []byte("/Type /Page")) - bytes.Count(pdf, []byte("/Type /Pages"))
+ return countEither(pdf, "/Type /Page") - countEither(pdf, "/Type /Pages")
}
func TestRenderMakesOnePagePerSVG(t *testing.T) {
@@ -73,7 +88,8 @@ func TestRenderMakesOnePagePerSVG(t *testing.T) {
if n := pageCount(pdf); n != 1 {
t.Errorf("pages = %d, want 1", n)
}
- if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) {
+ if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) &&
+ !bytes.Contains(pdf, []byte("/MediaBox[0 0 1920 1080]")) {
t.Error("the page is not 1920 by 1080 pt")
}
}
@@ -107,7 +123,7 @@ func TestRenderEmbedsTheFacesItWasGivenAsSubsets(t *testing.T) {
if !bytes.Contains(pdf, []byte("/FontFile2")) {
t.Error("no embedded TrueType program (/FontFile2)")
}
- if !bytes.Contains(pdf, []byte("/Subtype /Type0")) {
+ if !has(pdf, "/Subtype /Type0") {
t.Error("no Type0 font: text was flattened to paths")
}
if !bytes.Contains(pdf, []byte("/ToUnicode")) {
@@ -117,7 +133,7 @@ func TestRenderEmbedsTheFacesItWasGivenAsSubsets(t *testing.T) {
// bold weight resolves to the bold face rather than a synthesised
// one.
for _, face := range []string{"LiberationSerif", "LiberationSans-Bold"} {
- if !regexp.MustCompile(`/BaseFont /[A-Z]{6}\+` + face).Match(pdf) {
+ if !regexp.MustCompile(`/BaseFont ?/[A-Z]{6}\+` + face).Match(pdf) {
t.Errorf("no subset of %s in the PDF", face)
}
}
@@ -126,7 +142,7 @@ func TestRenderEmbedsTheFacesItWasGivenAsSubsets(t *testing.T) {
t.Errorf("PDF is %d bytes: the faces were embedded whole, not subset", len(pdf))
}
// The words are drawn with a text operator, not filled as paths.
- if plain := inflate(t, pdf); !bytes.Contains(plain, []byte(" Tj")) && !bytes.Contains(plain, []byte(" TJ")) {
+ if plain := inflate(t, pdf); !bytes.Contains(plain, []byte("Tj")) && !bytes.Contains(plain, []byte("TJ")) {
t.Error("no Tj/TJ in any content stream")
}
}
@@ -182,23 +198,25 @@ func TestADeadlineIsAnErrorNotAHang(t *testing.T) {
// failure, not a crash: the module's allocator aborts, wazero reports a
// trap, and the instance dies alone.
//
-// 24 pages is 1.5 MiB — just above the module's own static memory and
-// far below what one page of svg2pdf needs, so the ceiling is reached
-// on the first render instead of after allocating half a gibibyte to
-// find the real one (defaultMemoryPages). The same page at the default
-// limit is rendered afterwards, so this cannot pass on a broken SVG.
+// 32 pages is 2 MiB — just above the module's own static memory (25
+// pages since the krilla swap, which is why this is not the 24 it was)
+// and far below what one page of krilla-svg needs, so the ceiling is
+// reached on the first render instead of after allocating half a
+// gibibyte to find the real one (defaultMemoryPages). The same page at
+// the default limit is rendered afterwards, so this cannot pass on a
+// broken SVG.
func TestARenderPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) {
ctx := context.Background()
page := slide("Over the top", "Liberation Sans", 400)
- tight, err := pdfcore.New(ctx, pdfcore.WithMemoryLimitPages(24)) // 1.5 MiB
+ tight, err := pdfcore.New(ctx, pdfcore.WithMemoryLimitPages(32)) // 2 MiB
if err != nil {
- t.Fatalf("New with a 24-page limit: %v", err)
+ t.Fatalf("New with a 32-page limit: %v", err)
}
defer tight.Close(ctx)
out, err := tight.Render(ctx, [][]byte{page}, allFonts())
if err == nil {
- t.Fatalf("rendered %d bytes under a 24-page memory limit; the limit is not armed", len(out))
+ t.Fatalf("rendered %d bytes under a 32-page memory limit; the limit is not armed", len(out))
}
if pdfcore.Rejected(err) {
t.Errorf("running out of memory reads as a refusal of the deck: %v", err)
diff --git a/internal/slides/pdfcore/testdata/slides_pdf.wasm b/internal/slides/pdfcore/testdata/slides_pdf.wasm
index d3ac9ff..f46e7e9 100755
Binary files a/internal/slides/pdfcore/testdata/slides_pdf.wasm and b/internal/slides/pdfcore/testdata/slides_pdf.wasm differ
diff --git a/internal/slides/svg/svg.go b/internal/slides/svg/svg.go
index 5fba59e..627f8de 100644
--- a/internal/slides/svg/svg.go
+++ b/internal/slides/svg/svg.go
@@ -19,11 +19,10 @@
// stack slides.css names — which is the one place a thumbnail is
// knowingly not the slide, and why it is a thumbnail and not the
// export.
-// - The PDF, where the same bytes go through svg2pdf, which reads
-// PNG, JPEG and GIF and no WebP. A WebP asset is decoded and
-// re-encoded as a PNG on its way in (webpAsPNG), so a picture a
-// person uploaded in the format their phone writes is in both the
-// thumbnail and the PDF rather than in neither.
+// - The PDF, where the same bytes go through krilla-svg. A WebP asset
+// is decoded and re-encoded as a PNG on its way in (webpAsPNG), so a
+// picture a person uploaded in the format their phone writes is in
+// both the thumbnail and the PDF rather than in neither.
//
// # What comes out
//
@@ -77,10 +76,8 @@ type Options struct {
// this cannot use — and the picture falls back to AssetURL.
//
// PNG, JPEG, GIF and a sanitised SVG go in as they are. A WebP is
- // decoded here and re-encoded as a PNG (golang.org/x/image/webp),
- // because nothing downstream reads one: a browser rendering an
- // `<img src>` SVG fetches nothing, and svg2pdf has no WebP decoder
- // either. Anything else falls back to AssetURL.
+ // decoded here and re-encoded as a PNG (golang.org/x/image/webp).
+ // Anything else falls back to AssetURL.
InlineImages func(asset string) (data []byte, mime string, ok bool)
// AssetURL is where an asset's bytes are served from on THIS
@@ -320,12 +317,17 @@ var inlineTypes = []string{"image/png", "image/jpeg", "image/gif"}
//
// Re-encoding rather than refusing, because the alternative is a
// picture that is simply missing: WebP is one of the four types the
-// upload path admits (assets.go's assetTypes), it is what a phone and
-// a modern image editor write by default, and neither a browser
-// rendering an `<img src>` SVG nor svg2pdf will decode one. The cost is
-// a bigger data URI — PNG is lossless and WebP usually is not — which
-// is paid once per export, against a picture that would otherwise not
-// be on the slide at all.
+// upload path admits (assets.go's assetTypes), and it is what a phone
+// and a modern image editor write by default. The cost is a bigger data
+// URI — PNG is lossless and WebP usually is not — which is paid once per
+// export, against a picture that would otherwise not be on the slide at
+// all.
+//
+// The PDF converter has read WebP since svg2pdf was swapped for
+// krilla-svg (2026-09-09, spec §1 ruling 13), so on that side this is
+// now belt and braces. It is left in place because removing it is a
+// change to what the encoder emits, with its own tests, rather than a
+// consequence of changing the converter under it.
//
// A file that does not decode answers ok=false and the caller falls
// back to the URL, exactly as an unknown type does.
diff --git a/internal/slides/svg/svg_test.go b/internal/slides/svg/svg_test.go
index dfdf613..2d1404c 100644
--- a/internal/slides/svg/svg_test.go
+++ b/internal/slides/svg/svg_test.go
@@ -461,7 +461,8 @@ func TestAnImageFallsBackToTheSurfacesOwnURL(t *testing.T) {
}
}
-// The three raster types svg2pdf reads go in as they are.
+// The three raster types every consumer reads go in as they are; a
+// WebP is transcoded rather than inlined (see webpAsPNG).
func TestOnlyTypesEverythingCanReadAreInlined(t *testing.T) {
for _, mime := range []string{"image/png", "image/jpeg", "image/gif"} {
doc := parse(t, svg.Encode(frame(layout.Image{W: 10, H: 10, Asset: "asset0001aaa"}),
diff --git a/internal/slidestest/export_test.go b/internal/slidestest/export_test.go
index 4d71712..097073b 100644
--- a/internal/slidestest/export_test.go
+++ b/internal/slidestest/export_test.go
@@ -137,7 +137,13 @@ func TestExportJobProducesADownload(t *testing.T) {
}
// Notes were asked for, so there are at least two pages per slide.
- pages := strings.Count(string(pdf), "/Type /Page") - strings.Count(string(pdf), "/Type /Pages")
+ // Either spelling of the entry: pdf-writer 0.12 (svg2pdf) wrote
+ // "/Type /Page", 0.15 (krilla) writes "/Type/Page".
+ count := func(entry string) int {
+ return strings.Count(string(pdf), entry) +
+ strings.Count(string(pdf), strings.Replace(entry, " /", "/", 1))
+ }
+ pages := count("/Type /Page") - count("/Type /Pages")
if pages < 16 {
t.Errorf("the PDF has %d pages; the sample deck with notes should have at least 16", pages)
}