| 1 | // Package pdfcore hosts the Rust PDF module (core/pdf, `slides_pdf.wasm`) |
| 2 | // under wazero: SVG pages and TTF faces in, one PDF out. |
| 3 | // |
| 4 | // It is the second module beside the CRDT core (internal/slides/core) |
| 5 | // and keeps that host's rules, with one difference in shape. The CRDT |
| 6 | // host holds a long-lived instance per document and rebuilds it by |
| 7 | // replaying a log when it traps. This one has nothing to replay: a |
| 8 | // render is one call in, one PDF out, so every Render instantiates a |
| 9 | // fresh module, uses it once, and closes it. Nothing is shared between |
| 10 | // two renders, which is what makes a Runtime safe to use from many |
| 11 | // goroutines at once, and it is why a trap costs exactly the job that |
| 12 | // caused it. |
| 13 | // |
| 14 | // # Containment |
| 15 | // |
| 16 | // usvg and krilla-svg are parsers over bytes a deck author controls (an |
| 17 | // inlined image, a text run), and the module is built with |
| 18 | // `panic = "abort"`, so a bug in either is a wasm trap rather than an |
| 19 | // error. wazero turns a trap into an ordinary error from Call, the |
| 20 | // instance is closed, and Render reports it. Separately, a render can |
| 21 | // simply be slow — a very large PNG to decode, two hundred pages of |
| 22 | // dense text — and the Runtime is built WithCloseOnContextDone so a |
| 23 | // job that runs past its deadline (defaultGuestDeadline, sixty seconds, |
| 24 | // as against the CRDT host's one second: a PDF is a job, not a request) |
| 25 | // is terminated by wazero itself rather than left running after the |
| 26 | // caller has given up on it. |
| 27 | // |
| 28 | // The module wrapped svg2pdf until 2026-09-09 and wraps krilla and |
| 29 | // krilla-svg since; the ABI and this host did not change with it. See |
| 30 | // spec §1 ruling 13. |
| 31 | // |
| 32 | // A clean refusal — a malformed SVG, an empty page list — comes back |
| 33 | // from the module as a negative code with a message, wrapped here in |
| 34 | // RejectedError so a caller can tell "your deck has a problem" from |
| 35 | // "the renderer has a problem". |
| 36 | package pdfcore |
| 37 | |
| 38 | import ( |
| 39 | "context" |
| 40 | _ "embed" |
| 41 | "encoding/binary" |
| 42 | "errors" |
| 43 | "fmt" |
| 44 | "math" |
| 45 | "time" |
| 46 | |
| 47 | "github.com/tetratelabs/wazero" |
| 48 | "github.com/tetratelabs/wazero/api" |
| 49 | ) |
| 50 | |
| 51 | //go:embed testdata/slides_pdf.wasm |
| 52 | var moduleWASM []byte |
| 53 | |
| 54 | // abiVersion is the contract this host understands. A module reporting |
| 55 | // anything else is refused at load, so a stale .wasm (see |
| 56 | // scripts/build-pdf-wasm.sh and the copy into testdata) fails loudly |
| 57 | // rather than behaving strangely. It matches core/pdf/src/lib.rs's |
| 58 | // slides_pdf_abi_version. |
| 59 | const abiVersion = 1 |
| 60 | |
| 61 | // defaultGuestDeadline bounds one whole Render: instantiating the |
| 62 | // module, writing the frames, the render call, reading the result. |
| 63 | // Sixty seconds is a job's budget — spec §4.3 makes an export a job the |
| 64 | // status page polls — and it is far above the measured cost (about 150 |
| 65 | // ms for 200 pages of text; decoding large images is what moves it). |
| 66 | const defaultGuestDeadline = 60 * time.Second |
| 67 | |
| 68 | // maxFramesBytes bounds what one Render will write into the module: the |
| 69 | // frame lengths are u32 and the module's memory is 32-bit, so a payload |
| 70 | // past this could not be addressed even if the host were willing to |
| 71 | // send it. Two hundred slides of inlined 2000 px images fit many times |
| 72 | // over. |
| 73 | const maxFramesBytes = 1 << 30 |
| 74 | |
| 75 | // defaultMemoryPages caps the linear memory ONE instance may grow to: |
| 76 | // 8192 wasm pages of 64 KiB, which is 512 MiB. |
| 77 | // |
| 78 | // wasm32 memory grows to 4 GiB unless the host says otherwise, and this |
| 79 | // host runs a parser (usvg) over bytes a deck author controls, on a |
| 80 | // small instance shared with the database and every other request. The |
| 81 | // cap is what turns "a deck took the process down" into "one export |
| 82 | // failed": past it, `memory.grow` answers -1 inside the module, the |
| 83 | // Rust allocator's handler aborts, and wazero reports an ordinary trap |
| 84 | // from Call which Render wraps like any other failure. |
| 85 | // |
| 86 | // Half a gibibyte rather than something tighter: the measured ceiling |
| 87 | // is a 200-page render with inlined 2000 px images, and krilla holds the |
| 88 | // whole document until it writes it out (which is what lets it share one |
| 89 | // font subset across every page). A render that needs more than this is |
| 90 | // a deck nobody should be waiting on anyway. |
| 91 | const defaultMemoryPages = 8192 |
| 92 | |
| 93 | // Option configures a Runtime. See WithGuestDeadline. |
| 94 | type Option func(*Runtime) |
| 95 | |
| 96 | // WithGuestDeadline overrides defaultGuestDeadline for one Runtime, so |
| 97 | // the budget can come from configuration, or a test can force a timeout |
| 98 | // without waiting a minute for it. |
| 99 | func WithGuestDeadline(d time.Duration) Option { |
| 100 | return func(r *Runtime) { r.deadline = d } |
| 101 | } |
| 102 | |
| 103 | // WithMemoryLimitPages overrides defaultMemoryPages for one Runtime. |
| 104 | // Exists so the cap can come from configuration on a bigger host, and |
| 105 | // so a test can prove what happens at the ceiling without allocating |
| 106 | // half a gibibyte to get there. |
| 107 | func WithMemoryLimitPages(pages uint32) Option { |
| 108 | return func(r *Runtime) { r.memoryPages = pages } |
| 109 | } |
| 110 | |
| 111 | // Runtime owns the compiled module. Compiling is the expensive part |
| 112 | // (about three quarters of a second) and instantiating is not, so one |
| 113 | // Runtime is shared and every Render gets its own instance. Safe for |
| 114 | // concurrent use. |
| 115 | type Runtime struct { |
| 116 | rt wazero.Runtime |
| 117 | compiled wazero.CompiledModule |
| 118 | deadline time.Duration |
| 119 | memoryPages uint32 |
| 120 | } |
| 121 | |
| 122 | // New compiles the embedded module. It refuses a module that imports |
| 123 | // anything from the host (the zero-imports contract core/pdf's |
| 124 | // no_host_imports test also holds) and one that reports an ABI version |
| 125 | // this host does not understand. |
| 126 | func New(ctx context.Context, opts ...Option) (*Runtime, error) { |
| 127 | r := &Runtime{deadline: defaultGuestDeadline, memoryPages: defaultMemoryPages} |
| 128 | for _, opt := range opts { |
| 129 | opt(r) |
| 130 | } |
| 131 | |
| 132 | // WithCloseOnContextDone(true): without it, a deadline on a guest |
| 133 | // call only makes the caller stop waiting while the wasm keeps |
| 134 | // running to completion. With it, wazero terminates the call and |
| 135 | // closes the module when the context is done. |
| 136 | // |
| 137 | // WithMemoryLimitPages: the other half of the same containment — |
| 138 | // the deadline bounds how long one render may take, this bounds |
| 139 | // how much of the host's memory it may take with it. |
| 140 | cfg := wazero.NewRuntimeConfig(). |
| 141 | WithCloseOnContextDone(true). |
| 142 | WithMemoryLimitPages(r.memoryPages) |
| 143 | r.rt = wazero.NewRuntimeWithConfig(ctx, cfg) |
| 144 | compiled, err := r.rt.CompileModule(ctx, moduleWASM) |
| 145 | if err != nil { |
| 146 | r.rt.Close(ctx) |
| 147 | return nil, fmt.Errorf("compiling pdf module: %w", err) |
| 148 | } |
| 149 | if imports := compiled.ImportedFunctions(); len(imports) != 0 { |
| 150 | m, n, _ := imports[0].Import() |
| 151 | r.rt.Close(ctx) |
| 152 | return nil, fmt.Errorf("pdf module imports %s.%s from the host; it must import nothing", m, n) |
| 153 | } |
| 154 | r.compiled = compiled |
| 155 | |
| 156 | // Check the ABI once here rather than on every Render: the module |
| 157 | // bytes are embedded, so what it reports cannot change afterwards. |
| 158 | mod, err := r.instantiate(ctx) |
| 159 | if err != nil { |
| 160 | r.rt.Close(ctx) |
| 161 | return nil, err |
| 162 | } |
| 163 | mod.Close(ctx) |
| 164 | return r, nil |
| 165 | } |
| 166 | |
| 167 | // Close releases the compiled module. Renders in flight are terminated. |
| 168 | func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) } |
| 169 | |
| 170 | // instantiate creates a fresh instance and checks its ABI version. |
| 171 | func (r *Runtime) instantiate(ctx context.Context) (api.Module, error) { |
| 172 | // An anonymous module is exempt from wazero's name-uniqueness check, |
| 173 | // so many renders can instantiate at once. |
| 174 | mod, err := r.rt.InstantiateModule(ctx, r.compiled, wazero.NewModuleConfig().WithName("")) |
| 175 | if err != nil { |
| 176 | return nil, fmt.Errorf("instantiating pdf module: %w", err) |
| 177 | } |
| 178 | res, err := mod.ExportedFunction("slides_pdf_abi_version").Call(ctx) |
| 179 | if err != nil { |
| 180 | mod.Close(ctx) |
| 181 | return nil, fmt.Errorf("reading pdf module abi version: %w", err) |
| 182 | } |
| 183 | if got := uint32(res[0]); got != abiVersion { |
| 184 | mod.Close(ctx) |
| 185 | return nil, fmt.Errorf("pdf module reports ABI %d, host understands %d", got, abiVersion) |
| 186 | } |
| 187 | return mod, nil |
| 188 | } |
| 189 | |
| 190 | // RejectedError is a render the module cleanly refused — a malformed |
| 191 | // SVG, no pages — with the module's own message. The renderer is fine; |
| 192 | // the input is not. Use Rejected(err) rather than a type assertion. |
| 193 | type RejectedError struct { |
| 194 | Code int32 |
| 195 | Message string |
| 196 | } |
| 197 | |
| 198 | func (e *RejectedError) Error() string { |
| 199 | return fmt.Sprintf("pdf module refused the render (%d): %s", e.Code, e.Message) |
| 200 | } |
| 201 | |
| 202 | // Rejected reports whether err is a clean refusal by the module, as |
| 203 | // opposed to a trap, a deadline, or a host mistake. |
| 204 | func Rejected(err error) bool { |
| 205 | var r *RejectedError |
| 206 | return errors.As(err, &r) |
| 207 | } |
| 208 | |
| 209 | // Render draws each SVG in svgs as one page of a PDF, in order, with |
| 210 | // text set in the TTF faces in fonts (the SVG names a family; the |
| 211 | // module resolves weight and style against the faces given, so send |
| 212 | // every face the pages could use). Each SVG's width and height are the |
| 213 | // page's size in points. |
| 214 | // |
| 215 | // A fresh module instance serves the call and is discarded afterwards, |
| 216 | // whatever happened. The whole call runs under the Runtime's deadline |
| 217 | // on top of whatever ctx carries. |
| 218 | func (r *Runtime) Render(ctx context.Context, svgs, fonts [][]byte) ([]byte, error) { |
| 219 | ctx, cancel := context.WithTimeout(ctx, r.deadline) |
| 220 | defer cancel() |
| 221 | |
| 222 | svgFrames, err := frames(svgs) |
| 223 | if err != nil { |
| 224 | return nil, fmt.Errorf("pdf: pages: %w", err) |
| 225 | } |
| 226 | fontFrames, err := frames(fonts) |
| 227 | if err != nil { |
| 228 | return nil, fmt.Errorf("pdf: fonts: %w", err) |
| 229 | } |
| 230 | |
| 231 | mod, err := r.instantiate(ctx) |
| 232 | if err != nil { |
| 233 | return nil, err |
| 234 | } |
| 235 | // Whatever happens below — success, refusal, trap, deadline — the |
| 236 | // instance is not reused. A trap leaves linear memory as it was at |
| 237 | // the moment of the trap, and there is no log to rebuild from, so |
| 238 | // there is nothing to keep. |
| 239 | defer mod.Close(ctx) |
| 240 | |
| 241 | svgPtr, err := write(ctx, mod, svgFrames) |
| 242 | if err != nil { |
| 243 | return nil, fmt.Errorf("pdf: writing pages: %w", err) |
| 244 | } |
| 245 | fontPtr, err := write(ctx, mod, fontFrames) |
| 246 | if err != nil { |
| 247 | return nil, fmt.Errorf("pdf: writing fonts: %w", err) |
| 248 | } |
| 249 | |
| 250 | res, err := mod.ExportedFunction("slides_pdf_render").Call(ctx, |
| 251 | svgPtr, uint64(len(svgFrames)), fontPtr, uint64(len(fontFrames))) |
| 252 | if err != nil { |
| 253 | // A trap, a deadline, or the module closed under us: the call |
| 254 | // did not return, and the outcome is unknown. |
| 255 | return nil, fmt.Errorf("pdf: render did not complete: %w", err) |
| 256 | } |
| 257 | if rc := int32(res[0]); rc != 0 { |
| 258 | msg, rerr := read(ctx, mod, "err") |
| 259 | if rerr != nil { |
| 260 | return nil, fmt.Errorf("pdf: render refused (%d) and the message could not be read: %w", rc, rerr) |
| 261 | } |
| 262 | return nil, &RejectedError{Code: rc, Message: string(msg)} |
| 263 | } |
| 264 | pdf, err := read(ctx, mod, "out") |
| 265 | if err != nil { |
| 266 | return nil, fmt.Errorf("pdf: reading the result: %w", err) |
| 267 | } |
| 268 | if len(pdf) == 0 { |
| 269 | return nil, errors.New("pdf: render returned no bytes") |
| 270 | } |
| 271 | return pdf, nil |
| 272 | } |
| 273 | |
| 274 | // frames encodes items as the module's input shape: each item as a |
| 275 | // 4-byte big-endian length followed by its bytes. |
| 276 | func frames(items [][]byte) ([]byte, error) { |
| 277 | total := 0 |
| 278 | for _, it := range items { |
| 279 | if len(it) > math.MaxUint32 { |
| 280 | return nil, fmt.Errorf("frame of %d bytes is longer than a frame can say", len(it)) |
| 281 | } |
| 282 | total += 4 + len(it) |
| 283 | if total > maxFramesBytes { |
| 284 | return nil, fmt.Errorf("frames exceed %d bytes", maxFramesBytes) |
| 285 | } |
| 286 | } |
| 287 | out := make([]byte, 0, total) |
| 288 | for _, it := range items { |
| 289 | out = binary.BigEndian.AppendUint32(out, uint32(len(it))) |
| 290 | out = append(out, it...) |
| 291 | } |
| 292 | return out, nil |
| 293 | } |
| 294 | |
| 295 | // write copies data into the module's linear memory through |
| 296 | // slides_pdf_alloc and returns the pointer. The module frees it with |
| 297 | // the instance; nothing here calls slides_pdf_free because the instance |
| 298 | // is discarded after one render. |
| 299 | func write(ctx context.Context, mod api.Module, data []byte) (uint64, error) { |
| 300 | if len(data) == 0 { |
| 301 | // The module reads (0, 0) as no frames without touching the |
| 302 | // pointer (core/pdf/src/abi.rs, host_slice). |
| 303 | return 0, nil |
| 304 | } |
| 305 | res, err := mod.ExportedFunction("slides_pdf_alloc").Call(ctx, uint64(len(data))) |
| 306 | if err != nil { |
| 307 | return 0, fmt.Errorf("alloc: %w", err) |
| 308 | } |
| 309 | ptr := res[0] |
| 310 | if ptr == 0 { |
| 311 | return 0, fmt.Errorf("alloc(%d) failed", len(data)) |
| 312 | } |
| 313 | if !mod.Memory().Write(uint32(ptr), data) { |
| 314 | return 0, fmt.Errorf("writing %d bytes at %d: out of range", len(data), ptr) |
| 315 | } |
| 316 | return ptr, nil |
| 317 | } |
| 318 | |
| 319 | // read drains one of the module's _len/_read pairs ("out" or "err"). |
| 320 | func read(ctx context.Context, mod api.Module, pair string) ([]byte, error) { |
| 321 | res, err := mod.ExportedFunction("slides_pdf_" + pair + "_len").Call(ctx) |
| 322 | if err != nil { |
| 323 | return nil, fmt.Errorf("%s_len: %w", pair, err) |
| 324 | } |
| 325 | n := int32(res[0]) |
| 326 | if n < 0 { |
| 327 | return nil, fmt.Errorf("%s_len returned %d", pair, n) |
| 328 | } |
| 329 | if n == 0 { |
| 330 | return nil, nil |
| 331 | } |
| 332 | res, err = mod.ExportedFunction("slides_pdf_alloc").Call(ctx, uint64(n)) |
| 333 | if err != nil { |
| 334 | return nil, fmt.Errorf("alloc for %s: %w", pair, err) |
| 335 | } |
| 336 | ptr := res[0] |
| 337 | if ptr == 0 { |
| 338 | return nil, fmt.Errorf("alloc(%d) for %s failed", n, pair) |
| 339 | } |
| 340 | res, err = mod.ExportedFunction("slides_pdf_"+pair+"_read").Call(ctx, ptr, uint64(n)) |
| 341 | if err != nil { |
| 342 | return nil, fmt.Errorf("%s_read: %w", pair, err) |
| 343 | } |
| 344 | if got := int32(res[0]); got != n { |
| 345 | return nil, fmt.Errorf("%s_read wrote %d of %d bytes", pair, got, n) |
| 346 | } |
| 347 | view, ok := mod.Memory().Read(uint32(ptr), uint32(n)) |
| 348 | if !ok { |
| 349 | return nil, fmt.Errorf("reading %d bytes at %d: out of range", n, ptr) |
| 350 | } |
| 351 | // The view aliases linear memory, which the deferred Close releases. |
| 352 | out := make([]byte, n) |
| 353 | copy(out, view) |
| 354 | return out, nil |
| 355 | } |
| 356 | |