| 1 | package slides |
| 2 | |
| 3 | import ( |
| 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. |
| 34 | func 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. |
| 74 | func pdfCount(pdf []byte, entry string) int { |
| 75 | return bytes.Count(pdf, []byte(entry)) + |
| 76 | bytes.Count(pdf, []byte(strings.Replace(entry, " /", "/", 1))) |
| 77 | } |
| 78 | |
| 79 | func 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. |
| 88 | var 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. |
| 95 | func 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. |
| 108 | func 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 | |
| 115 | func 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 | |
| 195 | func 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 | |
| 217 | func 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. |
| 246 | func 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. |
| 265 | func 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 | |
| 275 | func 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 | |
| 373 | func 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 | |
| 419 | var ( |
| 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. |
| 433 | func 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. |
| 449 | func 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 | |
| 458 | func 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. |
| 527 | func 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. |
| 561 | func 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. |
| 628 | func 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. |
| 666 | func 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 | |