oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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".
36package pdfcore
37
38import (
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
52var 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.
59const 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).
66const 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.
73const 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.
91const defaultMemoryPages = 8192
92
93// Option configures a Runtime. See WithGuestDeadline.
94type 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.
99func 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.
107func 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.
115type 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.
126func 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.
168func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) }
169
170// instantiate creates a fresh instance and checks its ABI version.
171func (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.
193type RejectedError struct {
194 Code int32
195 Message string
196}
197
198func (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.
204func 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.
218func (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.
276func 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.
299func 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").
320func 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