oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3import (
4 "bytes"
5 "compress/zlib"
6 "context"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "image"
11 "image/color"
12 "image/png"
13 "io"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "regexp"
18 "sort"
19 "strconv"
20 "strings"
21 "sync"
22 "testing"
23 "time"
24
25 "amadan.net/oficina/slides/internal/slides/core"
26 "amadan.net/oficina/slides/internal/slides/deck"
27 "amadan.net/oficina/slides/internal/slides/layout"
28 "amadan.net/oficina/slides/internal/slides/pdfcore"
29)
30
31// sampleDeckForExport is the seeded sample deck (sample.go) as a view,
32// driven through the core so the export sees exactly what a real deck
33// is rather than a hand-written approximation of one.
34func sampleDeckForExport(t *testing.T) *deck.Deck {
35 t.Helper()
36 ctx := context.Background()
37 rt, err := core.New(ctx)
38 if err != nil {
39 t.Fatalf("core.New: %v", err)
40 }
41 t.Cleanup(func() { rt.Close(ctx) })
42 h, err := rt.NewDoc(ctx, "export-test", 1)
43 if err != nil {
44 t.Fatalf("NewDoc: %v", err)
45 }
46 t.Cleanup(func() { h.Close(ctx) })
47 ops, err := sampleOps()
48 if err != nil {
49 t.Fatal(err)
50 }
51 encoded, err := opsJSON(ops)
52 if err != nil {
53 t.Fatal(err)
54 }
55 if _, err := h.Ops(ctx, encoded); err != nil {
56 t.Fatalf("Ops: %v", err)
57 }
58 view, err := h.JSON(ctx)
59 if err != nil {
60 t.Fatalf("JSON: %v", err)
61 }
62 d, err := deck.Parse(view)
63 if err != nil {
64 t.Fatal(err)
65 }
66 return d
67}
68
69// The assertions here grep the PDF's bytes rather than parsing it, so
70// they take a dictionary entry in either spelling: pdf-writer 0.12
71// (which svg2pdf used) wrote "/Type /Page", and 0.15 (which krilla
72// uses) writes "/Type/Page". The object is the property under test, not
73// the space between the key and the value.
74func pdfCount(pdf []byte, entry string) int {
75 return bytes.Count(pdf, []byte(entry)) +
76 bytes.Count(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
77}
78
79func pdfPages(pdf []byte) int {
80 return pdfCount(pdf, "/Type /Page") - pdfCount(pdf, "/Type /Pages")
81}
82
83// exportRuntime is the compiled PDF module these tests render through,
84// built once for the whole binary. ExportOptions.Runtime is required
85// (the app owns one and closes it, exports.go); compiling costs the
86// best part of a second, so a test that built its own per call would be
87// paying it a dozen times over.
88var exportRuntime = sync.OnceValues(func() (*pdfcore.Runtime, error) {
89 return pdfcore.New(context.Background())
90})
91
92// exportOpts is ExportOptions with the shared runtime already in it —
93// every test's starting point, so none of them can accidentally assert
94// against a nil runtime's refusal.
95func exportOpts(t *testing.T, o ExportOptions) ExportOptions {
96 t.Helper()
97 rt, err := exportRuntime()
98 if err != nil {
99 t.Fatalf("pdfcore.New: %v", err)
100 }
101 o.Runtime = rt
102 return o
103}
104
105// TestExportNeedsARuntime: the module is the caller's to own, and
106// forgetting it is an error rather than a wasm compile hidden inside a
107// render.
108func TestExportNeedsARuntime(t *testing.T) {
109 d := sampleDeckForExport(t)
110 if _, _, err := Export(context.Background(), d, ExportOptions{}); err == nil {
111 t.Fatal("Export with no Runtime succeeded")
112 }
113}
114
115func TestExportSampleDeck(t *testing.T) {
116 d := sampleDeckForExport(t)
117 ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
118 defer cancel()
119
120 pdf, warnings, err := Export(ctx, d, exportOpts(t, ExportOptions{}))
121 if err != nil {
122 t.Fatalf("Export: %v", err)
123 }
124 if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
125 t.Fatalf("not a PDF: %q", pdf[:min(16, len(pdf))])
126 }
127 if got, want := pdfPages(pdf), len(d.Slides); got != want {
128 t.Errorf("pages = %d, want one per slide = %d", got, want)
129 }
130 for _, w := range warnings {
131 t.Logf("warning: %s %s: %s", w.Kind, w.Block, w.Detail)
132 }
133 for _, w := range warnings {
134 if w.Kind == layout.WarnAsset {
135 t.Errorf("the sample deck has no images, yet: %s", w.Detail)
136 }
137 }
138
139 // Every slide's heading is in the PDF as glyph runs: the content
140 // streams' Tj strings, mapped back through each font's ToUnicode
141 // CMap. Spaces are dropped on both sides because usvg positions each
142 // glyph on its own, so a word gap is a coordinate rather than a
143 // character.
144 text := strings.ReplaceAll(pdfText(t, pdf), " ", "")
145 var headings []string
146 for _, s := range d.Slides {
147 if h := slideTitle(s); h != "" {
148 headings = append(headings, h)
149 if !strings.Contains(text, strings.ReplaceAll(h, " ", "")) {
150 t.Errorf("heading %q is not in the PDF's text", h)
151 }
152 }
153 }
154 if len(headings) < 6 {
155 t.Fatalf("only %d headings in the sample deck; the check above proved little", len(headings))
156 }
157
158 // A real extractor, when one is around.
159 if path, err := exec.LookPath("pdftotext"); err == nil {
160 file := filepath.Join(t.TempDir(), "sample.pdf")
161 if err := os.WriteFile(file, pdf, 0o644); err != nil {
162 t.Fatal(err)
163 }
164 out, err := exec.Command(path, file, "-").Output()
165 if err != nil {
166 t.Fatalf("pdftotext: %v", err)
167 }
168 if !strings.Contains(string(out), headings[0]) {
169 t.Errorf("pdftotext did not find %q; got:\n%s", headings[0], out)
170 }
171 } else {
172 t.Logf("pdftotext is not on PATH: the external extractor assertion was skipped (the ToUnicode check above ran)")
173 }
174
175 // With notes: at least one notes page after every slide.
176 withNotes, _, err := Export(ctx, d, exportOpts(t, ExportOptions{WithNotes: true}))
177 if err != nil {
178 t.Fatalf("Export with notes: %v", err)
179 }
180 if got, want := pdfPages(withNotes), 2*len(d.Slides); got < want {
181 t.Errorf("pages with notes = %d, want at least %d", got, want)
182 }
183 notesText := strings.ReplaceAll(pdfText(t, withNotes), " ", "")
184 for _, s := range d.Slides {
185 plain := strings.ReplaceAll(strings.Fields(s.Notes.Plain())[0], " ", "")
186 if !strings.Contains(notesText, plain) {
187 t.Errorf("notes word %q of slide %s is not in the PDF's text", plain, s.ID)
188 }
189 }
190 if os.Getenv("SLIDES_WRITE_PDF") != "" {
191 os.WriteFile(os.Getenv("SLIDES_WRITE_PDF"), withNotes, 0o644)
192 }
193}
194
195func TestLongNotesBreakAcrossPages(t *testing.T) {
196 sentence := "Speaker notes that run on for a long time have to break across pages rather than run off the bottom of the one they started on. "
197 d := &deck.Deck{
198 Meta: deck.Meta{Title: "Notes", Theme: defaultTheme},
199 Slides: []deck.Slide{{
200 ID: "slide00000001", Layout: "flow", Template: "title",
201 Notes: deck.Text{{{T: strings.Repeat(sentence, 40)}}, {{T: "A second paragraph."}}},
202 Blocks: []deck.Block{{ID: "b00000000001", Type: "heading", Region: "heading", Props: map[string]any{"level": float64(1)}, Text: deck.Text{{{T: "One slide"}}}}},
203 }},
204 }
205 pdf, warnings, err := Export(context.Background(), d, exportOpts(t, ExportOptions{WithNotes: true}))
206 if err != nil {
207 t.Fatalf("Export: %v", err)
208 }
209 if got := pdfPages(pdf); got < 3 {
210 t.Errorf("pages = %d, want the slide plus at least two notes pages", got)
211 }
212 for _, w := range warnings {
213 t.Errorf("unexpected warning: %+v", w)
214 }
215}
216
217func TestExportRefusesOver200Slides(t *testing.T) {
218 build := func(n int) *deck.Deck {
219 d := &deck.Deck{Meta: deck.Meta{Title: "Big", Theme: defaultTheme}}
220 for i := 0; i < n; i++ {
221 d.Slides = append(d.Slides, deck.Slide{
222 ID: fmt.Sprintf("slide%08d", i), Layout: "flow", Template: "section",
223 Blocks: []deck.Block{{ID: fmt.Sprintf("b%011d", i), Type: "heading", Region: "heading",
224 Props: map[string]any{"level": float64(1)}, Text: deck.Text{{{T: fmt.Sprintf("Slide %d", i+1)}}}}},
225 })
226 }
227 return d
228 }
229 _, _, err := Export(context.Background(), build(MaxExportSlides+1), exportOpts(t, ExportOptions{}))
230 if !errors.Is(err, ErrTooManySlides) {
231 t.Fatalf("201 slides: err = %v, want ErrTooManySlides", err)
232 }
233
234 start := time.Now()
235 pdf, _, err := Export(context.Background(), build(MaxExportSlides), exportOpts(t, ExportOptions{}))
236 if err != nil {
237 t.Fatalf("200 slides: %v", err)
238 }
239 if got := pdfPages(pdf); got != MaxExportSlides {
240 t.Errorf("200 slides: pages = %d", got)
241 }
242 t.Logf("200 slides: %s, %d bytes", time.Since(start).Round(time.Millisecond), len(pdf))
243}
244
245// pngBytes is a w by h PNG filled with c.
246func pngBytes(t *testing.T, w, h int, c color.Color) []byte {
247 t.Helper()
248 img := image.NewNRGBA(image.Rect(0, 0, w, h))
249 for y := 0; y < h; y++ {
250 for x := 0; x < w; x++ {
251 img.Set(x, y, c)
252 }
253 }
254 var buf bytes.Buffer
255 if err := png.Encode(&buf, img); err != nil {
256 t.Fatal(err)
257 }
258 return buf.Bytes()
259}
260
261// webpBytes is a 4 by 3 lossless WebP of one flat colour. Written as
262// hex because Go has no WebP encoder — golang.org/x/image only decodes
263// one — and thirty-eight bytes in the test beats a testdata file
264// nobody can read.
265func webpBytes(t *testing.T) []byte {
266 t.Helper()
267 const file = "524946461e000000574542505650384c110000002f0380000007508f2217a5ff8188e87f0000"
268 raw, err := hex.DecodeString(file)
269 if err != nil {
270 t.Fatalf("the WebP fixture is not hex: %v", err)
271 }
272 return raw
273}
274
275func TestExportInlinesImagesAndDropsWhatItCannot(t *testing.T) {
276 assets := map[string]struct {
277 data []byte
278 mime string
279 }{
280 "small": {pngBytes(t, 300, 200, color.NRGBA{200, 30, 30, 255}), "image/png"},
281 "big": {pngBytes(t, 3000, 1500, color.NRGBA{30, 30, 200, 255}), "image/png"},
282 "junk": {[]byte("RIFF\x00\x00\x00\x00WEBPnot really"), "image/webp"},
283 "huge": {make([]byte, maxExportAsset+1), "image/png"},
284 // A real WebP — one of the four types the upload path admits.
285 // the SVG encoder cannot inline one, so it is decoded and re-encoded on
286 // the way in rather than dropped (golang.org/x/image/webp).
287 "webp": {webpBytes(t), "image/webp"},
288 }
289 ids := []string{"small", "big", "junk", "huge", "missing", "webp"}
290 d := &deck.Deck{
291 Meta: deck.Meta{Title: "Pictures", Theme: defaultTheme},
292 Assets: map[string]deck.Asset{},
293 }
294 for i, id := range ids {
295 d.Assets[id] = deck.Asset{Ref: "ref-" + id, Name: id + ".png", Width: 300, Height: 200, Alt: id}
296 d.Slides = append(d.Slides, deck.Slide{
297 ID: fmt.Sprintf("slide%08d", i), Layout: "flow", Template: "image-full",
298 Blocks: []deck.Block{{ID: fmt.Sprintf("b%011d", i), Type: "image", Region: "media",
299 Props: map[string]any{"asset": id}}},
300 })
301 }
302 var asked []string
303 opts := exportOpts(t, ExportOptions{Assets: func(asset string) ([]byte, string, bool) {
304 asked = append(asked, asset)
305 a, ok := assets[asset]
306 return a.data, a.mime, ok
307 }})
308
309 pdf, warnings, err := Export(context.Background(), d, opts)
310 if err != nil {
311 t.Fatalf("Export: %v", err)
312 }
313 if got := pdfPages(pdf); got != len(ids) {
314 t.Errorf("pages = %d, want %d", got, len(ids))
315 }
316
317 // Three pictures made it in: the small one as it was stored, the
318 // big one scaled to 2000 on its long edge, and the WebP re-encoded
319 // as a PNG at its own 4 by 3.
320 if got := pdfCount(pdf, "/Subtype /Image"); got != 3 {
321 t.Errorf("image XObjects = %d, want 3 (small, big and the WebP)", got)
322 }
323 for _, want := range []string{"/Width 300", "/Height 200", "/Width 2000", "/Height 1000", "/Width 4", "/Height 3"} {
324 if !bytes.Contains(pdf, []byte(want)) {
325 t.Errorf("PDF lacks %q", want)
326 }
327 }
328 if bytes.Contains(pdf, []byte("/Width 3000")) {
329 t.Error("the big image went in unscaled")
330 }
331
332 // Three did not, each with a warning saying which and why, and
333 // nothing in the file points anywhere outside it.
334 dropped := map[string]bool{}
335 for _, w := range warnings {
336 if w.Kind != layout.WarnAsset {
337 t.Errorf("unexpected warning: %+v", w)
338 continue
339 }
340 for _, id := range []string{"junk", "huge", "missing"} {
341 if strings.Contains(w.Detail, fmt.Sprintf("%q", id)) {
342 dropped[id] = true
343 }
344 }
345 t.Logf("warning: %s", w.Detail)
346 }
347 for _, id := range []string{"junk", "huge", "missing"} {
348 if !dropped[id] {
349 t.Errorf("no warning names the dropped image %q", id)
350 }
351 }
352 if len(dropped) != 3 || len(warnings) != 3 {
353 t.Errorf("warnings = %d, want exactly 3", len(warnings))
354 }
355 if bytes.Contains(pdf, []byte("/URI")) || bytes.Contains(pdf, []byte("http")) {
356 t.Error("the PDF refers to something outside itself")
357 }
358 sort.Strings(asked)
359 if got := strings.Join(asked, ","); got != "big,huge,junk,missing,small,webp" {
360 t.Errorf("assets asked for: %s", got)
361 }
362
363 // No way to read assets at all: every picture is left out, loudly.
364 _, warnings, err = Export(context.Background(), d, exportOpts(t, ExportOptions{}))
365 if err != nil {
366 t.Fatalf("Export without Assets: %v", err)
367 }
368 if len(warnings) != len(ids) {
369 t.Errorf("without Assets: %d warnings, want one per image (%d)", len(warnings), len(ids))
370 }
371}
372
373func TestDownscaleIsABoxFilterThatKeepsTheAspect(t *testing.T) {
374 // 4000 by 2000 comes down to 2000 by 1000.
375 wide := image.NewNRGBA(image.Rect(0, 0, 4000, 2000))
376 if b := downscale(wide, maxImageEdge).Bounds(); b.Dx() != 2000 || b.Dy() != 1000 {
377 t.Errorf("wide: %v", b)
378 }
379 // 1000 by 3000 comes down to 666 by 2000, not 2000 by 6000.
380 tall := image.NewNRGBA(image.Rect(0, 0, 1000, 3000))
381 if b := downscale(tall, maxImageEdge).Bounds(); b.Dx() != 666 || b.Dy() != 2000 {
382 t.Errorf("tall: %v", b)
383 }
384 // Small enough is untouched, the same value back.
385 small := image.NewNRGBA(image.Rect(0, 0, 10, 10))
386 if downscale(small, maxImageEdge) != small {
387 t.Error("a small image was copied")
388 }
389 // A 2 by 2 checkerboard averaged to one pixel is mid grey: the
390 // filter averages, it does not pick a corner.
391 board := image.NewNRGBA(image.Rect(0, 0, 2, 2))
392 board.Set(0, 0, color.White)
393 board.Set(1, 1, color.White)
394 board.Set(1, 0, color.Black)
395 board.Set(0, 1, color.Black)
396 r, g, b, _ := downscale(board, 1).At(0, 0).RGBA()
397 for _, v := range []uint32{r, g, b} {
398 if v>>8 < 126 || v>>8 > 129 {
399 t.Errorf("checkerboard averaged to %d, want about 128", v>>8)
400 }
401 }
402}
403
404// ── A small text extractor ───────────────────────────────────────────
405//
406// pdfText reads the words back out of a PDF the module wrote: every
407// FlateDecode stream inflated, every Type0 font's ToUnicode CMap
408// parsed, and every page's content stream walked for Tf and Tj, mapping
409// each two-byte CID through the font that Tf selected. It is enough to
410// prove the text is there as glyphs with a Unicode mapping, which is
411// what "select and copy from the PDF" needs; it is not an extractor
412// anyone should use for anything else.
413//
414// The font resource names are page-local — krilla calls the first font
415// on every page /f0 — so the name is resolved through that page's own
416// /Resources dictionary and never through a document-wide map, which
417// would read slide 4's heading in slide 3's face.
418
419var (
420 objRE = regexp.MustCompile(`(?m)^(\d+) 0 obj\n`)
421 toUnicodeRE = regexp.MustCompile(`/ToUnicode ?(\d+) 0 R`)
422 contentsRE = regexp.MustCompile(`/Contents ?(\d+) 0 R`)
423 resourcesRE = regexp.MustCompile(`/Resources ?(\d+) 0 R`)
424 kidsRE = regexp.MustCompile(`/Kids\s*\[([^\]]*)\]`)
425 refRE = regexp.MustCompile(`(\d+) 0 R`)
426 fontRefRE = regexp.MustCompile(`/([A-Za-z]\w*) ?(\d+) 0 R`)
427 // One pass over a content stream in operator order: a font
428 // selection, or a string shown. Anything else is skipped.
429 textOpRE = regexp.MustCompile(`(?s)/([A-Za-z]\w*) [\d.]+ Tf|\(((?:\\.|[^()\\])*)\)\s*Tj`)
430)
431
432// pdfObjects splits an inflated PDF into its numbered objects.
433func pdfObjects(plain []byte) map[int][]byte {
434 objects := map[int][]byte{}
435 locs := objRE.FindAllSubmatchIndex(plain, -1)
436 for i, loc := range locs {
437 n, _ := strconv.Atoi(string(plain[loc[2]:loc[3]]))
438 end := len(plain)
439 if i+1 < len(locs) {
440 end = locs[i+1][0]
441 }
442 objects[n] = plain[loc[1]:end]
443 }
444 return objects
445}
446
447// streamBody is the bytes between "stream" and "endstream", which
448// inflateStreams has already made plain.
449func streamBody(obj []byte) ([]byte, bool) {
450 i := bytes.Index(obj, []byte("stream\n"))
451 j := bytes.LastIndex(obj, []byte("endstream"))
452 if i < 0 || j < i {
453 return nil, false
454 }
455 return obj[i+len("stream\n") : j], true
456}
457
458func pdfText(t *testing.T, pdf []byte) string {
459 t.Helper()
460 objects := pdfObjects(inflateStreams(t, pdf))
461
462 // Font object number -> CID -> text.
463 cmaps := map[int]map[uint16]string{}
464 for n, body := range objects {
465 if !bytes.Contains(body, []byte("/Subtype /Type0")) &&
466 !bytes.Contains(body, []byte("/Subtype/Type0")) {
467 continue
468 }
469 m := toUnicodeRE.FindSubmatch(body)
470 if m == nil {
471 t.Fatalf("font %d has no ToUnicode", n)
472 }
473 ref, _ := strconv.Atoi(string(m[1]))
474 cmaps[n] = parseCMap(t, objects[ref])
475 }
476
477 var out strings.Builder
478 for _, n := range pdfPageOrder(objects) {
479 page := objects[n]
480
481 // This page's own font names.
482 fontsByName := map[string]map[uint16]string{}
483 if m := resourcesRE.FindSubmatch(page); m != nil {
484 ref, _ := strconv.Atoi(string(m[1]))
485 res := objects[ref]
486 if i := bytes.Index(res, []byte("/Font<<")); i >= 0 {
487 j := bytes.Index(res[i:], []byte(">>"))
488 for _, f := range fontRefRE.FindAllSubmatch(res[i:i+j], -1) {
489 fref, _ := strconv.Atoi(string(f[2]))
490 fontsByName[string(f[1])] = cmaps[fref]
491 }
492 }
493 }
494
495 m := contentsRE.FindSubmatch(page)
496 if m == nil {
497 continue
498 }
499 ref, _ := strconv.Atoi(string(m[1]))
500 content, ok := streamBody(objects[ref])
501 if !ok {
502 continue
503 }
504
505 var cur map[uint16]string
506 for _, op := range textOpRE.FindAllSubmatch(content, -1) {
507 if len(op[1]) > 0 {
508 cur = fontsByName[string(op[1])]
509 continue
510 }
511 raw := pdfLiteral(string(op[2]))
512 for k := 0; k+1 < len(raw); k += 2 {
513 cid := uint16(raw[k])<<8 | uint16(raw[k+1])
514 if cur != nil {
515 out.WriteString(cur[cid])
516 }
517 }
518 }
519 out.WriteByte('\n')
520 }
521 return out.String()
522}
523
524// pdfPageOrder is the page objects in the order the page tree lists
525// them, so extracted text comes out slide by slide rather than in
526// whatever order the objects happen to be numbered.
527func pdfPageOrder(objects map[int][]byte) []int {
528 var tree []byte
529 for _, body := range objects {
530 if bytes.Contains(body, []byte("/Type /Pages")) ||
531 bytes.Contains(body, []byte("/Type/Pages")) {
532 tree = body
533 break
534 }
535 }
536 if m := kidsRE.FindSubmatch(tree); m != nil {
537 var order []int
538 for _, k := range refRE.FindAllSubmatch(m[1], -1) {
539 n, _ := strconv.Atoi(string(k[1]))
540 order = append(order, n)
541 }
542 return order
543 }
544 // No page tree found: fall back to every page object, in number
545 // order, which is at least deterministic.
546 var order []int
547 for n, body := range objects {
548 isPage := bytes.Contains(body, []byte("/Type /Page")) ||
549 bytes.Contains(body, []byte("/Type/Page"))
550 isTree := bytes.Contains(body, []byte("/Type /Pages")) ||
551 bytes.Contains(body, []byte("/Type/Pages"))
552 if isPage && !isTree {
553 order = append(order, n)
554 }
555 }
556 sort.Ints(order)
557 return order
558}
559
560// parseCMap reads bfchar and bfrange entries out of a ToUnicode CMap.
561func parseCMap(t *testing.T, body []byte) map[uint16]string {
562 t.Helper()
563 m := map[uint16]string{}
564 hex := func(s string) []byte {
565 s = strings.Trim(s, "<>")
566 out := make([]byte, 0, len(s)/2)
567 for i := 0; i+1 < len(s); i += 2 {
568 v, err := strconv.ParseUint(s[i:i+2], 16, 8)
569 if err != nil {
570 t.Fatalf("cmap hex %q: %v", s, err)
571 }
572 out = append(out, byte(v))
573 }
574 return out
575 }
576 utf16be := func(b []byte) string {
577 var rs []rune
578 for i := 0; i+1 < len(b); i += 2 {
579 rs = append(rs, rune(uint16(b[i])<<8|uint16(b[i+1])))
580 }
581 return string(rs)
582 }
583 cid := func(b []byte) uint16 {
584 if len(b) != 2 {
585 t.Fatalf("cmap cid of %d bytes", len(b))
586 }
587 return uint16(b[0])<<8 | uint16(b[1])
588 }
589 lines := strings.Split(string(body), "\n")
590 mode := ""
591 for _, ln := range lines {
592 ln = strings.TrimSpace(ln)
593 switch {
594 case strings.HasSuffix(ln, "beginbfchar"):
595 mode = "char"
596 case strings.HasSuffix(ln, "beginbfrange"):
597 mode = "range"
598 case ln == "endbfchar" || ln == "endbfrange":
599 mode = ""
600 case mode == "char":
601 f := strings.Fields(ln)
602 if len(f) == 2 {
603 m[cid(hex(f[0]))] = utf16be(hex(f[1]))
604 }
605 case mode == "range":
606 f := strings.Fields(ln)
607 if len(f) == 3 && strings.HasPrefix(f[2], "<") {
608 lo, hi := cid(hex(f[0])), cid(hex(f[1]))
609 dst := hex(f[2])
610 for c := lo; c <= hi; c++ {
611 m[c] = utf16be(dst)
612 dst = bytes.Clone(dst)
613 if len(dst) > 0 {
614 dst[len(dst)-1]++
615 }
616 if c == 0xFFFF {
617 break
618 }
619 }
620 }
621 }
622 }
623 return m
624}
625
626// pdfLiteral decodes the inside of a PDF literal string: the backslash
627// escapes and octal codes.
628func pdfLiteral(s string) []byte {
629 var out []byte
630 for i := 0; i < len(s); i++ {
631 c := s[i]
632 if c != '\\' || i+1 >= len(s) {
633 out = append(out, c)
634 continue
635 }
636 i++
637 switch e := s[i]; e {
638 case 'n':
639 out = append(out, '\n')
640 case 'r':
641 out = append(out, '\r')
642 case 't':
643 out = append(out, '\t')
644 case 'b':
645 out = append(out, '\b')
646 case 'f':
647 out = append(out, '\f')
648 case '0', '1', '2', '3', '4', '5', '6', '7':
649 v := 0
650 k := 0
651 for ; k < 3 && i+k < len(s) && s[i+k] >= '0' && s[i+k] <= '7'; k++ {
652 v = v*8 + int(s[i+k]-'0')
653 }
654 i += k - 1
655 out = append(out, byte(v))
656 default:
657 out = append(out, e)
658 }
659 }
660 return out
661}
662
663// inflateStreams returns pdf with every FlateDecode stream replaced by
664// its plain bytes. Streams are found by the direct /Length pdf-writer
665// always writes.
666func inflateStreams(t *testing.T, pdf []byte) []byte {
667 t.Helper()
668 var out []byte
669 marker := []byte(">>\nstream\n")
670 pos := 0
671 for {
672 i := bytes.Index(pdf[pos:], marker)
673 if i < 0 {
674 break
675 }
676 dictEnd := pos + i
677 start := dictEnd + len(marker)
678 dictStart := bytes.LastIndex(pdf[:dictEnd], []byte(" 0 obj"))
679 if dictStart < 0 {
680 dictStart = 0
681 }
682 dict := pdf[dictStart:dictEnd]
683 j := bytes.Index(dict, []byte("/Length "))
684 if j < 0 {
685 t.Fatal("stream dictionary without /Length")
686 }
687 digits := dict[j+8:]
688 k := 0
689 for k < len(digits) && digits[k] >= '0' && digits[k] <= '9' {
690 k++
691 }
692 n, err := strconv.Atoi(string(digits[:k]))
693 if err != nil {
694 t.Fatal(err)
695 }
696 end := start + n
697 out = append(out, pdf[pos:start]...)
698 raw := pdf[start:end]
699 if bytes.Contains(dict, []byte("FlateDecode")) {
700 if z, err := zlib.NewReader(bytes.NewReader(raw)); err == nil {
701 if plain, err := io.ReadAll(z); err == nil {
702 out = append(out, plain...)
703 pos = end
704 continue
705 }
706 }
707 }
708 out = append(out, raw...)
709 pos = end
710 }
711 return append(out, pdf[pos:]...)
712}
713