oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// export.go turns a deck into a PDF (spec §4.3): one 1920 by 1080 pt
2// page per slide, and when asked, the speaker notes as a page after
3// each slide.
4//
5// The pipeline is the one the plan's global constraints lay down and
6// nothing here shortcuts it: layout.Layout draws each slide into a
7// paint list, svg.Encode writes that paint list as an SVG, and the Rust
8// module under internal/slides/pdfcore turns the SVGs into a PDF with
9// the text as glyphs from the same Liberation faces the layout engine
10// measured with. The PDF path never reads a deck; a notes page is a
11// paint list too, built here from the notes text and typeset with the
12// same fonts package, so it goes through the same two steps.
13//
14// # Self-contained
15//
16// A PDF is a file somebody emails. Every image is inlined into its
17// page's SVG before the module sees it, and an image that cannot be
18// inlined — the bytes are not there, or are not a picture this build
19// can decode, or are bigger than the cap — is left out with a Warning
20// that says so, never replaced by a URL the reader's machine would have
21// to fetch. Images are downscaled to maxImageEdge on the long edge
22// first (§4.3), because a 6000 px photograph on a 1920 pt page is bytes
23// nobody sees.
24//
25// The routes, the job and the status page are in the export handlers;
26// this file is the function they call.
27package slides
28
29import (
30 "bytes"
31 "context"
32 "errors"
33 "fmt"
34 "image"
35 "image/draw"
36 "image/jpeg"
37 "image/png"
38 "strings"
39
40 // The decoders Assets can hand back. Registering them is what makes
41 // image.Decode recognise the bytes; a format nobody registered is
42 // "not a picture this build can decode" and is dropped with a
43 // warning rather than sent to the module, which would fail the
44 // whole render on it (core/pdf/src/lib.rs).
45 _ "image/gif"
46
47 // WebP is one of the four types the upload path admits (§2.7):
48 // registering the decoder is what sends it down the
49 // decode-and-re-encode path below, where it comes out as a PNG.
50 // Without it a picture somebody uploaded from a phone was simply
51 // missing from their handout. (krilla-svg does decode WebP, unlike
52 // the svg2pdf it replaced, so the re-encode is belt and braces on
53 // the PDF side now; dropping it is its own change, with its own
54 // tests, not a side effect of swapping the converter.)
55 _ "golang.org/x/image/webp"
56
57 "amadan.net/oficina/slides/internal/slides/deck"
58 "amadan.net/oficina/slides/internal/slides/fonts"
59 "amadan.net/oficina/slides/internal/slides/layout"
60 "amadan.net/oficina/slides/internal/slides/pdfcore"
61 "amadan.net/oficina/slides/internal/slides/svg"
62)
63
64// MaxExportSlides is the most slides one export will take (§4.3). A
65// deck past it is refused before any work is done, with
66// ErrTooManySlides.
67const MaxExportSlides = 200
68
69// ErrTooManySlides is Export's refusal of a deck over MaxExportSlides.
70var ErrTooManySlides = errors.New("slides: the deck has more slides than an export will take")
71
72// maxExportAsset bounds one image's stored bytes: 20 MiB, the plan's
73// cap. Bigger is dropped with a warning, not decoded.
74const maxExportAsset = 20 << 20
75
76// maxImagePixels bounds what one image is allowed to decode to. The
77// byte cap above says nothing about pixels — a 20 MiB PNG can be a
78// hundred thousand pixels a side — and decoding is where the memory
79// goes. Fifty megapixels is an 8000 by 6000 photograph; anything larger
80// is dropped with a warning.
81const maxImagePixels = 50_000_000
82
83// maxImageEdge is the long edge an inlined image is scaled down to
84// (§4.3). Slightly over the page's own 1920, so a full-bleed picture is
85// not upscaled by the reader.
86const maxImageEdge = 2000
87
88// ExportOptions is what Export needs beyond the deck.
89type ExportOptions struct {
90 // WithNotes puts a page of speaker notes after every slide (a slide
91 // with long notes gets more than one). Every slide gets one, notes
92 // or not, so the pages pair up slide, notes, slide, notes all the
93 // way through and a printed handout is predictable.
94 WithNotes bool
95
96 // Assets returns an image's stored bytes and MIME type by the
97 // deck's asset id (layout.Image.Asset). It is the caller's because
98 // where the bytes live — the object store, a directory — is the
99 // app's business and not the PDF's. ok=false is "not available",
100 // which is a warning and an omitted image. nil means no image can
101 // be fetched at all: every image is omitted, each with a warning.
102 Assets func(asset string) (data []byte, mime string, ok bool)
103
104 // Runtime is the compiled PDF module this export renders through.
105 // Required. Compiling one costs the best part of a second and a
106 // Runtime is safe for concurrent use, so it belongs to whatever
107 // owns the process — the app builds one and closes it (exports.go's
108 // pdfRuntime), a test builds one per package — and never to a
109 // single export. Nil is an error rather than a module compiled
110 // here: a function that quietly compiles a wasm module per call is
111 // a function whose cost nobody can see.
112 Runtime *pdfcore.Runtime
113
114 // Progress, when set, is called before each slide is laid out, with
115 // the slide's 1-based number and the total. It is how an export
116 // running as a job tells its status page how far along it is. The
117 // words are the caller's: they are a person's words, and this
118 // package has no locale.
119 Progress func(slide, total int)
120}
121
122// Export renders d as a PDF. The warnings are everything worth telling
123// the person who asked — a block that overflowed, an image that was
124// left out, a character no face can draw — and never stop the export;
125// the error is what does: a deck over MaxExportSlides, a renderer that
126// refused or trapped, a deadline.
127func Export(ctx context.Context, d *deck.Deck, o ExportOptions) ([]byte, []layout.Warning, error) {
128 if d == nil {
129 return nil, nil, errors.New("slides: export: no deck")
130 }
131 if len(d.Slides) > MaxExportSlides {
132 return nil, nil, fmt.Errorf("%w: %d slides, the most is %d", ErrTooManySlides, len(d.Slides), MaxExportSlides)
133 }
134 if len(d.Slides) == 0 {
135 return nil, nil, errors.New("slides: export: the deck has no slides")
136 }
137 if o.Runtime == nil {
138 return nil, nil, errors.New("slides: export: no PDF runtime (ExportOptions.Runtime)")
139 }
140
141 x := &exporter{deck: d, opts: o}
142 var pages [][]byte
143 for i := range d.Slides {
144 if err := ctx.Err(); err != nil {
145 return nil, x.warnings, err
146 }
147 // The slide every warning from here on is about — the ones the
148 // layout engine stamps for itself, and the ones this file makes
149 // about an image it could not use.
150 x.slide = d.Slides[i].ID
151 if o.Progress != nil {
152 o.Progress(i+1, len(d.Slides))
153 }
154 p, warnings, err := layout.Layout(d, i)
155 if err != nil {
156 return nil, x.warnings, fmt.Errorf("slides: export: slide %d: %w", i+1, err)
157 }
158 x.warnings = append(x.warnings, warnings...)
159 title := slideTitle(d.Slides[i])
160 if title == "" {
161 title = d.Meta.Title
162 }
163 pages = append(pages, x.encode(p, title))
164 if o.WithNotes {
165 for _, np := range x.notesPages(d.Slides[i], title) {
166 pages = append(pages, x.encode(np, title))
167 }
168 }
169 }
170
171 var faces [][]byte
172 for _, f := range fonts.All() {
173 faces = append(faces, fonts.Data(f))
174 }
175 pdf, err := o.Runtime.Render(ctx, pages, faces)
176 if err != nil {
177 return nil, x.warnings, fmt.Errorf("slides: export: %w", err)
178 }
179 return pdf, x.warnings, nil
180}
181
182// exporter is one Export call: the deck, the options, and what has
183// been noticed so far.
184type exporter struct {
185 deck *deck.Deck
186 opts ExportOptions
187 warnings []layout.Warning
188
189 // slide is the id of the slide being rendered, stamped onto every
190 // warning made here so the status page can say which slide lost
191 // its picture (layout.Warning.Slide).
192 slide string
193
194 // inlined caches an asset's inlined form for the length of one
195 // export, so a picture on ten slides is fetched and scaled once.
196 inlined map[string]inlinedImage
197}
198
199type inlinedImage struct {
200 data []byte
201 mime string
202 ok bool
203}
204
205func (x *exporter) warn(kind, block, detail string) {
206 x.warnings = append(x.warnings, layout.Warning{
207 Kind: kind, Slide: x.slide, Block: block, Detail: detail,
208 })
209}
210
211// encode writes one paint list as a self-contained SVG page.
212func (x *exporter) encode(p layout.Paint, title string) []byte {
213 return svg.Encode(p, svg.Options{
214 Title: title,
215 InlineImages: x.inline,
216 // No AssetURL: an image that is not inlined is not drawn. A URL
217 // in a PDF is a picture the reader's machine has to fetch, and
218 // the module would refuse it anyway.
219 })
220}
221
222// slideTitle is the slide's first heading, as plain text, or "".
223func slideTitle(s deck.Slide) string {
224 for _, b := range s.Blocks {
225 if b.Type == "heading" {
226 return strings.TrimSpace(b.Text.Plain())
227 }
228 }
229 return ""
230}
231
232// ── Images ───────────────────────────────────────────────────────────
233
234// inline is the svg.Options.InlineImages hook: the asset's bytes,
235// decoded, scaled down to maxImageEdge if they are bigger, and handed
236// back as a picture the PDF module accepts. ok=false is "leave it out",
237// and every path to it has already recorded why.
238func (x *exporter) inline(asset string) ([]byte, string, bool) {
239 if x.inlined == nil {
240 x.inlined = map[string]inlinedImage{}
241 }
242 if c, done := x.inlined[asset]; done {
243 return c.data, c.mime, c.ok
244 }
245 data, mime, ok := x.fetchImage(asset)
246 x.inlined[asset] = inlinedImage{data, mime, ok}
247 return data, mime, ok
248}
249
250func (x *exporter) fetchImage(asset string) ([]byte, string, bool) {
251 if x.opts.Assets == nil {
252 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q: no way to read assets was given; left out of the PDF", asset))
253 return nil, "", false
254 }
255 data, mime, ok := x.opts.Assets(asset)
256 if !ok {
257 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be read; left out of the PDF", asset))
258 return nil, "", false
259 }
260 if len(data) > maxExportAsset {
261 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d bytes, over the %d byte cap; left out of the PDF", asset, len(data), maxExportAsset))
262 return nil, "", false
263 }
264 if mime == "image/svg+xml" {
265 // A vector picture is already what the page is made of: the
266 // encoder nests it into the slide (it was sanitised on
267 // registration, svg.go). The one it will not nest is a picture
268 // carrying a stylesheet, which would restyle the slide around
269 // it; the encoder falls back to the asset's URL, and this
270 // export gives it none, so say why the picture is missing
271 // rather than letting it vanish.
272 if bytes.Contains(bytes.ToLower(data), []byte("<style")) {
273 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is an SVG with a stylesheet, which cannot be nested into a page; left out of the PDF", asset))
274 return nil, "", false
275 }
276 return data, mime, true
277 }
278
279 cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
280 if err != nil {
281 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) is not a picture this build can decode; left out of the PDF", asset, mime))
282 return nil, "", false
283 }
284 if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
285 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q is %d by %d pixels, more than the PDF will take; left out", asset, cfg.Width, cfg.Height))
286 return nil, "", false
287 }
288 if max(cfg.Width, cfg.Height) <= maxImageEdge && (format == "png" || format == "jpeg" || format == "gif") {
289 // Small enough and in a format the module reads: the stored
290 // bytes go in untouched, which for a JPEG means DCTDecode
291 // passthrough rather than a second lossy generation.
292 return data, "image/" + format, true
293 }
294
295 img, _, err := image.Decode(bytes.NewReader(data))
296 if err != nil {
297 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q (%s) could not be decoded: %v; left out of the PDF", asset, mime, err))
298 return nil, "", false
299 }
300 scaled := downscale(img, maxImageEdge)
301 var buf bytes.Buffer
302 if format == "jpeg" {
303 // A photograph stays a photograph: JPEG in, JPEG out, at a
304 // quality the eye does not separate from the original on a
305 // page.
306 if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 88}); err != nil {
307 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err))
308 return nil, "", false
309 }
310 return buf.Bytes(), "image/jpeg", true
311 }
312 if err := png.Encode(&buf, scaled); err != nil {
313 x.warn(layout.WarnAsset, "", fmt.Sprintf("image %q could not be re-encoded: %v; left out of the PDF", asset, err))
314 return nil, "", false
315 }
316 return buf.Bytes(), "image/png", true
317}
318
319// downscale returns img no larger than edge on its long side, or img
320// itself if it already is.
321//
322// A box filter, written here because golang.org/x/image/draw is not
323// the standard library and the stdlib's image/draw only copies. Each
324// target pixel is the mean of the source rectangle it covers, in
325// premultiplied RGBA so a transparent edge does not bleed its colour
326// into its neighbours. For a reduction — the only direction this is
327// used in — a box filter is the right filter: it is what "average the
328// pixels you are merging" means, and it does not ring.
329func downscale(img image.Image, edge int) image.Image {
330 b := img.Bounds()
331 sw, sh := b.Dx(), b.Dy()
332 if sw <= edge && sh <= edge {
333 return img
334 }
335 tw, th := edge, edge
336 if sw >= sh {
337 th = max(1, sh*edge/sw)
338 } else {
339 tw = max(1, sw*edge/sh)
340 }
341
342 src := image.NewRGBA(image.Rect(0, 0, sw, sh))
343 draw.Draw(src, src.Bounds(), img, b.Min, draw.Src)
344 dst := image.NewRGBA(image.Rect(0, 0, tw, th))
345 for ty := 0; ty < th; ty++ {
346 y0, y1 := ty*sh/th, (ty+1)*sh/th
347 if y1 <= y0 {
348 y1 = y0 + 1
349 }
350 for tx := 0; tx < tw; tx++ {
351 x0, x1 := tx*sw/tw, (tx+1)*sw/tw
352 if x1 <= x0 {
353 x1 = x0 + 1
354 }
355 var r, g, bl, a uint64
356 for y := y0; y < y1; y++ {
357 row := src.Pix[y*src.Stride+x0*4 : y*src.Stride+x1*4]
358 for i := 0; i < len(row); i += 4 {
359 r += uint64(row[i])
360 g += uint64(row[i+1])
361 bl += uint64(row[i+2])
362 a += uint64(row[i+3])
363 }
364 }
365 n := uint64((y1 - y0) * (x1 - x0))
366 o := ty*dst.Stride + tx*4
367 dst.Pix[o] = uint8((r + n/2) / n)
368 dst.Pix[o+1] = uint8((g + n/2) / n)
369 dst.Pix[o+2] = uint8((bl + n/2) / n)
370 dst.Pix[o+3] = uint8((a + n/2) / n)
371 }
372 }
373 return dst
374}
375
376// ── Notes pages ──────────────────────────────────────────────────────
377
378// The notes page's geometry, in the layout engine's own units (1920 by
379// 1080, the padded scale from layout/sizes.go: 1 cqw = 16.8 px inside a
380// section with the stylesheet's 6.25% padding). The sizes are the
381// stylesheet's t-3 and body-m, so a notes page reads as the deck's own
382// type rather than as a different document stapled to it.
383const (
384 notesPad = 0.0625 * layout.FrameW // --slides-pad
385 notesCqw = (layout.FrameW - 2*notesPad) / 100
386 notesTitle = 4.0 * notesCqw // --slides-t-3
387 notesBody = 2.8 * notesCqw // --slides-body-m
388 notesGap = 2.6 * notesCqw // --slides-gap, title to text
389 notesLead = 1.3 // the section's line-height
390 notesLeadT = 1.1 // a heading's line-height
391 notesParaGap = 0.6 // em between paragraphs, as slides.css
392 notesBg = layout.Fill("#ffffff")
393 notesFg = layout.Fill("#111111")
394)
395
396// notesPages typesets s's speaker notes: the slide's title at t-3, then
397// the notes at body-m in Liberation Sans, greedily wrapped and broken
398// across as many 1920 by 1080 pages as they need. A slide with no notes
399// still gets its page (see ExportOptions.WithNotes).
400//
401// Plain text: marks are dropped, as §2.6's notes are read aloud, not
402// looked at. Nothing is ever clipped — a word wider than the page runs
403// past the margin, the same rule the layout engine keeps.
404func (x *exporter) notesPages(s deck.Slide, title string) []layout.Paint {
405 titleFace := fonts.Face{Family: fonts.Sans, Bold: true}
406 bodyFace := fonts.Face{Family: fonts.Sans}
407 colW := layout.FrameW - 2*notesPad
408 bottom := layout.FrameH - notesPad
409
410 var pages []layout.Paint
411 cur := layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg}
412 top := notesPad
413
414 // place puts one wrapped block on the page, breaking to a new page
415 // wherever the next line would cross the bottom margin.
416 place := func(lines []string, face fonts.Face, size, lead float64) {
417 asc, desc, _ := fonts.Metrics(face, size)
418 lineH := lead * size
419 for _, ln := range lines {
420 if top+lineH > bottom && len(cur.Ops) > 0 {
421 pages = append(pages, cur)
422 cur = layout.Paint{W: layout.FrameW, H: layout.FrameH, Background: notesBg}
423 top = notesPad
424 }
425 baseline := top + (lineH-(asc+desc))/2 + asc
426 if ln != "" {
427 cur.Ops = append(cur.Ops, layout.Text{X: notesPad, Y: baseline, Runs: []layout.Run{
428 {S: ln, Face: face, Size: size, Fill: notesFg},
429 }})
430 }
431 top += lineH
432 }
433 }
434
435 if title != "" {
436 place(x.wrapNotes(s.ID, title, titleFace, notesTitle, colW), titleFace, notesTitle, notesLeadT)
437 top += notesGap
438 }
439 for i, para := range s.Notes {
440 if i > 0 {
441 top += notesParaGap * notesBody
442 }
443 text := strings.TrimSpace(plainParagraph(para))
444 if text == "" {
445 // An empty paragraph is an empty line box, as <p></p> is.
446 top += notesLead * notesBody
447 continue
448 }
449 place(x.wrapNotes(s.ID, text, bodyFace, notesBody, colW), bodyFace, notesBody, notesLead)
450 }
451 return append(pages, cur)
452}
453
454// plainParagraph is one paragraph's runs concatenated, marks dropped.
455func plainParagraph(para []deck.Run) string {
456 var b strings.Builder
457 for _, r := range para {
458 b.WriteString(r.T)
459 }
460 return b.String()
461}
462
463// wrapNotes breaks text into lines no wider than w, greedily, measured
464// with the same fonts package the layout engine measures with. A rune
465// no face has a glyph for is reported once per slide.
466func (x *exporter) wrapNotes(block, text string, face fonts.Face, size, w float64) []string {
467 space, _ := fonts.Advance(face, size, " ")
468 var lines []string
469 var cur strings.Builder
470 var curW float64
471 var missing []rune
472 for _, word := range strings.Fields(text) {
473 ww, miss := fonts.Advance(face, size, word)
474 missing = append(missing, miss...)
475 switch {
476 case cur.Len() == 0:
477 cur.WriteString(word)
478 curW = ww
479 case curW+space+ww > w:
480 lines = append(lines, cur.String())
481 cur.Reset()
482 cur.WriteString(word)
483 curW = ww
484 default:
485 cur.WriteByte(' ')
486 cur.WriteString(word)
487 curW += space + ww
488 }
489 }
490 if cur.Len() > 0 {
491 lines = append(lines, cur.String())
492 }
493 if len(missing) > 0 {
494 seen := map[rune]bool{}
495 var fresh []string
496 for _, r := range missing {
497 if !seen[r] {
498 seen[r] = true
499 fresh = append(fresh, fmt.Sprintf("%q", r))
500 }
501 }
502 x.warn(layout.WarnGlyph, block, fmt.Sprintf(
503 "the notes use %s, which no embedded face has a glyph for; drawn at the width of a box",
504 strings.Join(fresh, ", ")))
505 }
506 return lines
507}
508