Repository navigation
Expand file tree
/
Copy pathtest.js
More file actions
1326 lines (1203 loc) · 46.6 KB
/
Copy pathtest.js
File metadata and controls
1326 lines (1203 loc) · 46.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const test = require('brittle')
const fs = require('bare-fs')
const os = require('bare-os')
const crypto = require('bare-crypto')
const addon = require('.')
// Assemble a PDF from object bodies with a correct xref, so no binary fixture
// has to be checked in. Latin1 keeps every byte 1:1 for the offset math.
function buildPdf(bodies, trailer = '') {
let pdf = '%PDF-1.4\n'
const offsets = []
bodies.forEach((body, i) => {
offsets.push(pdf.length)
pdf += `${i + 1} 0 obj\n${body}\nendobj\n`
})
const xrefStart = pdf.length
pdf += `xref\n0 ${bodies.length + 1}\n0000000000 65535 f \n`
for (const offset of offsets) pdf += `${String(offset).padStart(10, '0')} 00000 n \n`
pdf += `trailer\n<< /Size ${bodies.length + 1} /Root 1 0 R ${trailer}>>\nstartxref\n${xrefStart}\n%%EOF`
return Buffer.from(pdf, 'latin1')
}
function textPdf() {
const content = 'BT /F1 24 Tf 40 100 Td (Hello PDF) Tj ET'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
// A page whose only content is a 2x2 image scaled to fill it — no text layer.
function imagePdf() {
const draw = 'q 200 0 0 200 0 0 cm /Im0 Do Q'
// 2x2 RGB: red, green, blue, white
const px = '\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>',
`<< /Length ${draw.length} >>\nstream\n${draw}\nendstream`,
`<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length ${px.length} >>\nstream\n${px}\nendstream`
])
}
function formXObjectPdf() {
const page = 'q 1 0 0 1 0 0 cm /Fm0 Do Q'
const form = 'q 100 0 0 50 20 30 cm /Im0 Do Q BT /F1 12 Tf 20 120 Td (Inside a form) Tj ET'
const px = '\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>',
`<< /Length ${page.length} >>\nstream\n${page}\nendstream`,
`<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Matrix [2 0 0 2 10 10] /Resources << /XObject << /Im0 6 0 R >> /Font << /F1 7 0 R >> >> /Length ${form.length} >>\nstream\n${form}\nendstream`,
`<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length ${px.length} >>\nstream\n${px}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
function navigationPdf() {
const one = 'BT /F1 24 Tf 40 100 Td (Page One) Tj ET'
const two = 'BT /F1 24 Tf 40 100 Td (Page Two) Tj ET'
return buildPdf(
[
'<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /P (A-) /S /D >>] >> >>',
'<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> /Annots [12 0 R 13 0 R] >>',
`<< /Length ${one.length} >>\nstream\n${one}\nendstream`,
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>',
`<< /Length ${two.length} >>\nstream\n${two}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 3 >>',
'<< /Title (Intro) /Parent 8 0 R /Next 10 0 R /Dest [3 0 R /Fit] /First 11 0 R /Last 11 0 R /Count 1 >>',
'<< /Title (Second) /Parent 8 0 R /Prev 9 0 R /Dest [5 0 R /Fit] >>',
'<< /Title (Detail) /Parent 9 0 R /Dest [5 0 R /Fit] >>',
'<< /Type /Annot /Subtype /Link /Rect [10 10 60 30] /A << /S /URI /URI (https://example.com/docs) >> >>',
'<< /Type /Annot /Subtype /Link /Rect [10 150 60 170] /Dest [5 0 R /Fit] >>',
'<< /Title (A navigation test) /Author (bare-pdfium) /Subject (Outlines, labels and links) >>'
],
'/Info 14 0 R '
)
}
function twoPageTextPdf() {
const one = 'BT /F1 24 Tf 40 100 Td (Page One) Tj ET'
const two = 'BT /F1 24 Tf 40 100 Td (Page Two) Tj ET'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>',
`<< /Length ${one.length} >>\nstream\n${one}\nendstream`,
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>',
`<< /Length ${two.length} >>\nstream\n${two}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
function hyphenPdf() {
const content = 'BT /F1 12 Tf 20 150 Td (the infer-) Tj 0 -14 Td (ence book) Tj ET'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
function offPagePdf() {
const content = 'BT /F1 12 Tf 20 100 Td (inside) Tj ET BT /F1 12 Tf 300 50 Td (outside) Tj ET'
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
function encryptedPdf() {
const hash = '<' + 'ab'.repeat(32) + '>'
return buildPdf(
[
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>',
`<< /Filter /Standard /V 1 /R 2 /O ${hash} /U ${hash} /P -4 >>`
],
`/Encrypt 4 0 R /ID [<00112233445566778899aabbccddeeff> <00112233445566778899aabbccddeeff>] `
)
}
test('pageCount: one-shot and via a handle agree', (t) => {
t.is(addon.pageCount(textPdf()), 1)
const doc = addon.open(textPdf())
t.is(doc.pageCount(), 1)
doc.close()
})
test('pageSize reports points without rendering', (t) => {
const doc = addon.open(textPdf())
t.alike(doc.pageSize(0), { width: 200, height: 200 })
doc.close()
})
test('render returns RGBA sized by scale, white background', (t) => {
const { width, height, data } = addon.render(textPdf(), 0, { scale: 2 })
t.is(width, 400)
t.is(height, 400)
t.is(data.length, width * height * 4)
t.alike([data[0], data[1], data[2], data[3]], [255, 255, 255, 255])
})
test('pageFlags classifies a text page', (t) => {
const doc = addon.open(textPdf())
t.alike(doc.pageFlags(0), { hasImage: false, hasText: true })
t.is(doc.extractImages(0).length, 0, 'a text page yields no images')
doc.close()
})
test('pageFlags and extractImages find a page image', (t) => {
const doc = addon.open(imagePdf())
const flags = doc.pageFlags(0)
t.is(flags.hasImage, true, 'the full-page image is detected')
t.is(flags.hasText, false, 'an image-only page has no text layer')
const images = doc.extractImages(0)
t.ok(images.length >= 1, 'the embedded image is extracted')
const [first] = images
t.is(first.data.length, first.width * first.height * 4, 'extracted image is RGBA')
doc.close()
})
test('extractImages reports where each image sits on the page', (t) => {
const doc = addon.open(imagePdf())
const [image] = doc.extractImages(0)
t.alike(image.bounds, { x: 0, y: 0, width: 200, height: 200 })
doc.close()
})
test('pageFlags and extractImages look inside form XObjects', (t) => {
const doc = addon.open(formXObjectPdf())
t.alike(doc.pageFlags(0), { hasImage: true, hasText: true })
const images = doc.extractImages(0)
t.is(images.length, 1, 'the image drawn by the form is found')
t.alike(
images[0].bounds,
{ x: 50, y: 70, width: 200, height: 100 },
'bounds include the form matrix'
)
t.alike(doc.imageBounds(0), [images[0].bounds], 'imageBounds sees the form image too')
doc.close()
})
test('pageFlags hasText agrees with the text layer on every fixture page', (t) => {
const path = require('bare-path')
let pages = 0
for (const dir of ['test/fixtures/pdfium', 'test/fixtures/hostile']) {
for (const name of fs
.readdirSync(path.join(__dirname, dir))
.filter((f) => f.endsWith('.pdf'))) {
let doc
try {
doc = addon.open(fs.readFileSync(path.join(__dirname, dir, name)))
} catch {
continue
}
for (let page = 0; page < Math.min(doc.pageCount(), 20); page++) {
let flags, text
try {
flags = doc.pageFlags(page)
text = doc.extractText(page, { hyphens: 'raw' })
} catch {
continue
}
pages++
if (flags.hasText !== text.length > 0) {
t.fail(`${name} page ${page}: hasText ${flags.hasText}`)
}
}
doc.close()
}
}
t.ok(pages > 100, `${pages} pages agree`)
})
test('extractText returns the page text layer', (t) => {
const doc = addon.open(textPdf())
t.ok(doc.extractText(0).includes('Hello PDF'), 'the drawn text is extracted')
doc.close()
})
test('extractText is empty for a page with no text layer', (t) => {
const doc = addon.open(imagePdf())
t.is(doc.extractText(0), '', 'an image-only page yields no text')
doc.close()
})
test('extractText rejects an out-of-range page', (t) => {
const doc = addon.open(textPdf())
t.exception.all(() => doc.extractText(5))
doc.close()
})
test('textPages streams every page in order', (t) => {
const doc = addon.open(twoPageTextPdf())
const pages = [...doc.textPages()]
t.is(pages.length, 2)
t.is(pages[0].page, 0)
t.ok(pages[0].text.includes('Page One'), 'first page text')
t.is(pages[1].page, 1)
t.ok(pages[1].text.includes('Page Two'), 'second page text')
doc.close()
})
test('textPages one-shot opens and closes around the stream', (t) => {
const pages = [...addon.textPages(twoPageTextPdf())]
t.is(pages.length, 2)
t.ok(pages[1].text.includes('Page Two'), 'reaches the last page')
})
test('openFile reads a PDF from disk on demand', (t) => {
const file = `${os.tmpdir()}/bare-pdfium-${Date.now()}-${Math.random().toString(16).slice(2)}.pdf`
fs.writeFileSync(file, twoPageTextPdf())
try {
const doc = addon.openFile(file)
t.is(doc.pageCount(), 2, 'reads the page count')
t.ok(doc.extractText(0).includes('Page One'), 'reads page text')
t.is([...doc.textPages()].length, 2, 'streams both pages')
doc.close()
} finally {
fs.unlinkSync(file)
}
})
test('openFile throws on a missing path', (t) => {
t.exception(() => addon.openFile(`${os.tmpdir()}/bare-pdfium-does-not-exist.pdf`))
})
test('open accepts a password on an unencrypted PDF', (t) => {
const doc = addon.open(textPdf(), { password: 'unused' })
t.is(doc.pageCount(), 1)
doc.close()
})
test('close is idempotent', (t) => {
const doc = addon.open(textPdf())
doc.close()
doc.close()
t.pass('a second close does not throw')
})
test('render does not deadlock when a GC finalizes unclosed documents', (t) => {
for (let i = 0; i < 200; i++) addon.open(textPdf())
for (let i = 0; i < 100; i++) addon.render(textPdf(), 0, { scale: 10 })
t.pass()
})
test('render rejects an out-of-range page', (t) => {
t.exception.all(() => addon.render(textPdf(), 5, { scale: 1 }))
})
test('extractText joins a word hyphenated across a line break by default', (t) => {
const doc = addon.open(hyphenPdf())
const text = doc.extractText(0)
t.ok(text.includes('inference'), 'the word is rejoined')
t.absent(text.includes('\uFFFE'), 'no U+FFFE marker is left')
doc.close()
})
test('extractText hyphens option keeps or passes through the marker', (t) => {
const doc = addon.open(hyphenPdf())
t.ok(doc.extractText(0, { hyphens: 'keep' }).includes('infer-ence'), 'keep restores the hyphen')
t.ok(doc.extractText(0, { hyphens: 'raw' }).includes('infer\uFFFEence'), 'raw is untouched')
t.exception.all(() => doc.extractText(0, { hyphens: 'nope' }), /hyphens must be/)
doc.close()
})
test('extractText clip drops text outside the page box', (t) => {
const doc = addon.open(offPagePdf())
t.ok(doc.extractText(0).includes('outside'), 'unclipped text includes off-page text')
const clipped = doc.extractText(0, { clip: true })
t.ok(clipped.includes('inside'), 'on-page text is kept')
t.absent(clipped.includes('outside'), 'off-page text is dropped')
doc.close()
})
test('extractText clip handles the hyphen marker like the unclipped path', (t) => {
const doc = addon.open(hyphenPdf())
t.ok(doc.extractText(0, { clip: true }).includes('inference'), 'join rejoins the word')
t.ok(
doc.extractText(0, { clip: true, hyphens: 'keep' }).includes('infer-ence'),
'keep restores the hyphen'
)
t.ok(
doc.extractText(0, { clip: true, hyphens: 'raw' }).includes('infer\uFFFEence'),
'raw carries U+FFFE'
)
t.absent(
doc.extractText(0, { clip: true, hyphens: 'raw' }).includes('\u0002'),
'no U+0002 is left'
)
doc.close()
})
test('extractText clip is empty for a page with no text layer', (t) => {
const doc = addon.open(imagePdf())
t.is(doc.extractText(0, { clip: true }), '')
doc.close()
})
test('open reports FORMAT for bytes that are not a PDF', (t) => {
try {
addon.open(Buffer.from('not a pdf'))
t.fail('should throw')
} catch (err) {
t.is(err.code, 'FORMAT')
}
})
test('open reports PASSWORD for an encrypted PDF without its password', (t) => {
try {
addon.open(encryptedPdf())
t.fail('should throw')
} catch (err) {
t.is(err.code, 'PASSWORD')
}
})
test('openFile reports FILE for a missing path', (t) => {
try {
addon.openFile(`${os.tmpdir()}/bare-pdfium-does-not-exist.pdf`)
t.fail('should throw')
} catch (err) {
t.is(err.code, 'FILE')
}
})
test('extractTextAsync matches extractText', async (t) => {
const doc = addon.open(hyphenPdf())
t.is(await doc.extractTextAsync(0), doc.extractText(0))
t.is(await doc.extractTextAsync(0, { hyphens: 'raw' }), doc.extractText(0, { hyphens: 'raw' }))
doc.close()
})
test('extractTextAsync with clip drops off-page text', async (t) => {
const doc = addon.open(offPagePdf())
t.absent((await doc.extractTextAsync(0, { clip: true })).includes('outside'))
doc.close()
})
test('extractTextAsync with clip handles the hyphen marker', async (t) => {
const doc = addon.open(hyphenPdf())
for (const hyphens of ['join', 'keep', 'raw']) {
t.is(
await doc.extractTextAsync(0, { clip: true, hyphens }),
doc.extractText(0, { hyphens }),
hyphens
)
}
doc.close()
})
test('renderAsync matches render', async (t) => {
const doc = addon.open(textPdf())
const expected = doc.render(0, { scale: 2 })
const actual = await doc.renderAsync(0, { scale: 2 })
t.is(actual.width, expected.width)
t.is(actual.height, expected.height)
t.ok(Buffer.compare(actual.data, expected.data) === 0, 'same pixels')
doc.close()
})
function busyDoc() {
const doc = addon.create()
doc.addPage({ width: 600, height: 600 })
const items = []
for (let i = 0; i < 40000; i++) {
items.push({
type: 'rect',
x: (i * 7) % 590,
y: (i * 13) % 590,
width: 6,
height: 6,
fill: [i % 255, 80, 160],
opacity: 0.5
})
}
doc.draw(0, items)
return doc
}
test('render clip draws one region at the full-page scale', (t) => {
const doc = busyDoc()
const full = doc.render(0, { scale: 2 })
const tile = doc.render(0, { scale: 2, clip: { x: 100, y: 200, width: 150, height: 100 } })
t.alike([tile.width, tile.height], [300, 200])
let same = true
for (let y = 0; y < tile.height && same; y++) {
const row = tile.data.subarray(y * tile.width * 4, (y + 1) * tile.width * 4)
const start = ((y + 600) * full.width + 200) * 4
same = row.equals(full.data.subarray(start, start + tile.width * 4))
}
t.ok(same, 'the tile matches the same crop of a full render')
doc.close()
})
test('render clip validates its box', (t) => {
const doc = addon.open(textPdf())
t.exception.all(
() => doc.render(0, { clip: { x: 0, y: 0, width: 0, height: 10 } }),
/greater than 0/
)
t.exception.all(() => doc.render(0, { clip: { x: 'a', y: 0, width: 5, height: 10 } }), /clip x/)
t.exception.all(() => doc.render(0, { clip: 5 }), /clip must be an object/)
doc.close()
})
test('renderAsync with a signal renders the same pixels', async (t) => {
const { AbortController } = require('bare-abort-controller')
const doc = busyDoc()
const plain = doc.render(0, { scale: 1 })
const signalled = await doc.renderAsync(0, { scale: 1, signal: new AbortController().signal })
t.ok(plain.data.equals(signalled.data))
doc.close()
})
test('renderAsync rejects with ABORTED when its signal aborts', async (t) => {
const { AbortController } = require('bare-abort-controller')
const doc = busyDoc()
const early = new AbortController()
early.abort(new Error('before'))
await t.exception(doc.renderAsync(0, { signal: early.signal }), /aborted/)
const queued = new AbortController()
const first = doc.renderAsync(0, { scale: 3 })
const second = doc.renderAsync(0, { scale: 3, signal: queued.signal })
queued.abort(new Error('scrolled away'))
await first
const err = await second.catch((e) => e)
t.is(err.code, 'ABORTED', 'a queued render is skipped')
t.is(err.cause.message, 'scrolled away', 'the reason is kept as the cause')
const running = new AbortController()
const started = Date.now()
const pending = doc.renderAsync(0, { scale: 4, signal: running.signal })
setTimeout(() => running.abort(), 5)
const stopped = await pending.catch((e) => e)
t.is(stopped.code, 'ABORTED', `a running render stops (after ${Date.now() - started} ms)`)
await t.exception.all(doc.renderAsync(0, { signal: {} }), /AbortSignal/)
doc.close()
})
test('async calls reject on an out-of-range page', async (t) => {
const doc = addon.open(textPdf())
await t.exception.all(doc.extractTextAsync(5))
await t.exception.all(doc.renderAsync(5))
doc.close()
})
test('async calls interleave with sync calls and other documents', async (t) => {
const one = addon.open(twoPageTextPdf())
const two = addon.open(textPdf())
const jobs = []
for (let i = 0; i < 20; i++) {
jobs.push(one.extractTextAsync(i % 2), two.renderAsync(0), two.extractTextAsync(0))
t.ok(one.extractText(1).includes('Page Two'))
}
const results = await Promise.all(jobs)
t.ok(results[0].includes('Page One'))
t.ok(results[3].includes('Page Two'))
t.is(results[1].width, 200)
t.ok(results[2].includes('Hello PDF'))
one.close()
two.close()
})
test('async calls queued before close reject instead of crashing', async (t) => {
const doc = addon.open(textPdf())
const text = doc.extractTextAsync(0)
const image = doc.renderAsync(0)
doc.close()
await t.exception(text, /document is closed/)
await t.exception(image, /document is closed/)
await t.exception(doc.extractTextAsync(0), /document is closed/)
})
test('textPagesAsync streams every page in order', async (t) => {
const doc = addon.open(twoPageTextPdf())
const pages = []
for await (const page of doc.textPagesAsync()) pages.push(page)
t.is(pages.length, 2)
t.ok(pages[0].text.includes('Page One'))
t.ok(pages[1].text.includes('Page Two'))
doc.close()
})
function sizedPdf(width, height, text) {
const content = `BT /F1 12 Tf 10 10 Td (${text}) Tj ET`
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>`,
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'
])
}
function formPdf() {
const on = '0 g 2 2 11 11 re f'
const widget = '/Type /Annot /Subtype /Widget /P 3 0 R /F 4'
const states = (name) => `/AP << /N << /${name} 10 0 R /Off 11 0 R >> >>`
return buildPdf([
'<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 9 0 R 13 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 12 0 R >> >> >> >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R 5 0 R 7 0 R 8 0 R 9 0 R 13 0 R] >>',
`<< ${widget} /FT /Tx /T (name) /Rect [20 250 200 270] /DA (/Helv 12 Tf 0 g) >>`,
`<< ${widget} /FT /Btn /T (agree) /Rect [20 220 35 235] /V /Off /AS /Off ${states('Yes')} >>`,
'<< /FT /Btn /Ff 49152 /T (color) /V /Off /Kids [7 0 R 8 0 R] >>',
`<< ${widget} /Parent 6 0 R /Rect [20 190 35 205] /AS /Off ${states('red')} >>`,
`<< ${widget} /Parent 6 0 R /Rect [50 190 65 205] /AS /Off ${states('blue')} >>`,
`<< ${widget} /FT /Ch /Ff 131072 /T (size) /Opt [(S) (M) (L)] /Rect [20 150 120 170] /DA (/Helv 12 Tf 0 g) >>`,
`<< /Type /XObject /Subtype /Form /BBox [0 0 15 15] /Length ${on.length} >>\nstream\n${on}\nendstream`,
'<< /Type /XObject /Subtype /Form /BBox [0 0 15 15] /Length 0 >>\nstream\n\nendstream',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< ${widget} /FT /Tx /T (id) /Ff 1 /V (A-1) /Rect [20 120 200 140] /DA (/Helv 12 Tf 0 g) >>`
])
}
function rc4(key, data) {
const s = Array.from({ length: 256 }, (_, i) => i)
for (let i = 0, j = 0; i < 256; i++) {
j = (j + s[i] + key[i % key.length]) & 255
;[s[i], s[j]] = [s[j], s[i]]
}
const out = Buffer.alloc(data.length)
for (let n = 0, i = 0, j = 0; n < data.length; n++) {
i = (i + 1) & 255
j = (j + s[i]) & 255
;[s[i], s[j]] = [s[j], s[i]]
out[n] = data[n] ^ s[(s[i] + s[j]) & 255]
}
return out
}
function md5(...parts) {
return crypto.createHash('md5').update(Buffer.concat(parts)).digest()
}
function lockedPdf(password) {
const padding = Buffer.from(
'28bf4e5e4e758a4164004e56fffa01082e2e00b6d0683e802f0ca9fe6453697a',
'hex'
)
const pad = (text) => Buffer.concat([Buffer.from(text, 'latin1'), padding]).subarray(0, 32)
const id = Buffer.from('00112233445566778899aabbccddeeff', 'hex')
const permissions = Buffer.alloc(4)
permissions.writeInt32LE(-4)
const owner = rc4(md5(pad('owner')).subarray(0, 5), pad(password))
const key = md5(pad(password), owner, permissions, id).subarray(0, 5)
const user = rc4(key, padding)
const content = Buffer.from('BT /F1 24 Tf 40 100 Td (Secret) Tj ET', 'latin1')
const objectKey = md5(key, Buffer.from([4, 0, 0, 0, 0])).subarray(0, 10)
const stream = rc4(objectKey, content).toString('latin1')
return buildPdf(
[
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Filter /Standard /V 1 /R 2 /O <${owner.toString('hex')}> /U <${user.toString('hex')}> /P -4 >>`
],
`/Encrypt 6 0 R /ID [<${id.toString('hex')}> <${id.toString('hex')}>] `
)
}
function pageTexts(pdf, opts) {
return [...addon.textPages(pdf, opts)].map(({ text }) => text)
}
test('create and importPages build a document from other documents', (t) => {
const merged = addon.create()
t.is(merged.pageCount(), 0)
const one = addon.open(twoPageTextPdf())
const two = addon.open(textPdf())
merged.importPages(one, { pages: [1] })
merged.importPages(two)
merged.importPages(one, { pages: [0], at: 0 })
one.close()
two.close()
const texts = pageTexts(merged.save())
merged.close()
t.is(texts.length, 3)
t.ok(texts[0].includes('Page One'))
t.ok(texts[1].includes('Page Two'))
t.ok(texts[2].includes('Hello PDF'))
})
test('merge concatenates PDFs in order', (t) => {
const texts = pageTexts(addon.merge([textPdf(), twoPageTextPdf()]))
t.is(texts.length, 3)
t.ok(texts[0].includes('Hello PDF'))
t.ok(texts[2].includes('Page Two'))
})
test('merge rejects an empty list', (t) => {
t.exception.all(() => addon.merge([]), /array of PDFs/)
})
test('extractPages keeps and reorders the chosen pages', (t) => {
const texts = pageTexts(addon.extractPages(twoPageTextPdf(), [1, 0]))
t.is(texts.length, 2)
t.ok(texts[0].includes('Page Two'))
t.ok(texts[1].includes('Page One'))
})
test('extractPages rejects an out-of-range page', (t) => {
t.exception.all(() => addon.extractPages(textPdf(), [3]), /out of range/)
})
test('rotate turns pages clockwise and adds to the existing rotation', (t) => {
const once = addon.rotate(sizedPdf(200, 100, 'wide'), 90)
t.alike(addon.open(once).pageSize(0), { width: 100, height: 200 })
const twice = addon.rotate(once, -90)
t.alike(addon.open(twice).pageSize(0), { width: 200, height: 100 })
t.ok(pageTexts(once)[0].includes('wide'), 'content is kept')
})
test('rotate only touches the pages it is given', (t) => {
const doc = addon.open(addon.merge([sizedPdf(200, 100, 'a'), sizedPdf(200, 100, 'b')]))
doc.rotate(180, { pages: [1] })
const rotated = addon.open(doc.save())
doc.close()
t.alike(rotated.pageSize(0), { width: 200, height: 100 })
t.alike(rotated.pageSize(1), { width: 200, height: 100 })
rotated.close()
})
test('pageRotation reports the clockwise rotation of each page', (t) => {
const doc = addon.open(addon.merge([sizedPdf(200, 100, 'a'), sizedPdf(200, 100, 'b')]))
t.is(doc.pageRotation(0), 0)
doc.rotate(90, { pages: [0] })
doc.rotate(-90, { pages: [1] })
t.is(doc.pageRotation(0), 90)
t.is(doc.pageRotation(1), 270)
doc.rotate(90, { pages: [0] })
t.is(doc.pageRotation(0), 180)
t.is(
codeOf(() => doc.pageRotation(2)),
'PAGE_RANGE'
)
doc.close()
})
test('rotate rejects angles that are not a multiple of 90', (t) => {
t.exception.all(() => addon.rotate(textPdf(), 45), /multiple of 90/)
})
test('decrypt removes the password from an encrypted PDF', (t) => {
const locked = lockedPdf('s3cret')
try {
addon.open(locked)
t.fail('the fixture should need its password')
} catch (err) {
t.is(err.code, 'PASSWORD')
}
const unlocked = addon.decrypt(locked, 's3cret')
t.ok(pageTexts(unlocked)[0].includes('Secret'), 'opens without a password')
})
test('decrypt reports PASSWORD for a wrong password', (t) => {
try {
addon.decrypt(lockedPdf('s3cret'), 'nope')
t.fail('should throw')
} catch (err) {
t.is(err.code, 'PASSWORD')
}
})
test('save keeps the encryption unless asked to remove it', (t) => {
const doc = addon.open(lockedPdf('s3cret'), { password: 's3cret' })
const saved = doc.save()
doc.close()
t.exception(() => addon.open(saved), 'still needs the password')
t.ok(pageTexts(saved, { password: 's3cret' })[0].includes('Secret'))
})
test('watermark stamps text on every page', (t) => {
const stamped = addon.watermark(twoPageTextPdf(), 'DRAFT')
const texts = pageTexts(stamped)
t.ok(texts[0].includes('DRAFT') && texts[0].includes('Page One'))
t.ok(texts[1].includes('DRAFT') && texts[1].includes('Page Two'))
})
test('watermark can target pages and go under the content', (t) => {
const stamped = addon.watermark(twoPageTextPdf(), 'COPY', {
pages: [1],
position: 'under',
size: 30
})
const texts = pageTexts(stamped)
t.absent(texts[0].includes('COPY'))
t.ok(texts[1].includes('COPY'))
})
test('watermark is drawn inside the page and changes its pixels', (t) => {
const pdf = sizedPdf(300, 200, 'x')
const before = addon.render(pdf, 0).data
const after = addon.render(addon.watermark(pdf, 'DRAFT', { opacity: 1, color: [255, 0, 0] }), 0)
let red = 0
for (let i = 0; i < after.data.length; i += 4) {
if (after.data[i] > 200 && after.data[i + 1] < 80 && before[i + 1] > 200) red++
}
t.ok(red > 500, `the stamp paints red pixels (${red})`)
})
test('watermark validates its options', (t) => {
t.exception.all(() => addon.watermark(textPdf(), ''), /non-empty/)
t.exception.all(() => addon.watermark(textPdf(), 'x', { position: 'side' }), /position/)
t.exception.all(() => addon.watermark(textPdf(), 'x', { opacity: 2 }), /opacity/)
t.exception.all(() => addon.watermark(textPdf(), '草稿'), /TrueType font/)
t.exception.all(() => addon.watermark(textPdf(), '🌿🌿'), /nothing that can be drawn/)
t.ok(pageTexts(addon.watermark(textPdf(), '🌿 DRAFT'))[0].includes('DRAFT'), 'emoji are dropped')
})
test('fields lists the form with types, values and options', (t) => {
const fields = addon.fields(formPdf())
t.alike(
fields.map(({ name, type }) => [name, type]),
[
['name', 'text'],
['agree', 'checkbox'],
['color', 'radio'],
['size', 'combobox'],
['id', 'text']
]
)
const byName = Object.fromEntries(fields.map((field) => [field.name, field]))
t.is(byName.agree.value, false)
t.alike(byName.color.options, ['red', 'blue'])
t.is(byName.color.value, null)
t.alike(byName.size.options, ['S', 'M', 'L'])
t.is(byName.id.value, 'A-1')
t.is(byName.id.readOnly, true)
})
test('fields reports where each widget sits on its page', (t) => {
const doc = addon.open(formPdf())
const name = doc.fields().find((field) => field.name === 'name')
t.alike(name.widgets, [{ page: 0, x: 20, y: 250, width: 180, height: 20 }])
doc.close()
})
test('metadata reads the document information', (t) => {
const doc = addon.open(navigationPdf())
const info = doc.metadata()
t.is(info.title, 'A navigation test')
t.is(info.author, 'bare-pdfium')
t.is(info.subject, 'Outlines, labels and links')
t.is(info.keywords, null, 'a missing entry is null')
doc.close()
})
test('outline returns the bookmark tree with target pages', (t) => {
const doc = addon.open(navigationPdf())
t.alike(doc.outline(), [
{ title: 'Intro', page: 0, children: [{ title: 'Detail', page: 1, children: [] }] },
{ title: 'Second', page: 1, children: [] }
])
t.alike(addon.open(textPdf()).outline(), [], 'a PDF without an outline returns an empty list')
doc.close()
})
test('pageLabel follows the document page labels', (t) => {
const doc = addon.open(navigationPdf())
t.is(doc.pageLabel(0), 'i')
t.is(doc.pageLabel(1), 'A-1')
t.is(addon.open(textPdf()).pageLabel(0), null, 'no labels gives null')
doc.close()
})
test('links lists web and in-document links with their areas', (t) => {
const doc = addon.open(navigationPdf())
t.alike(doc.links(0), [
{ x: 10, y: 10, width: 50, height: 20, uri: 'https://example.com/docs', page: null },
{ x: 10, y: 150, width: 50, height: 20, uri: null, page: 1 }
])
t.alike(doc.links(1), [])
doc.close()
})
test('findText returns each match with its rectangles', (t) => {
const doc = addon.open(textPdf())
const [match, ...rest] = doc.findText(0, 'pdf')
t.is(rest.length, 0)
t.is(match.index, 6)
t.is(match.length, 3)
t.is(match.rects.length, 1)
const [rect] = match.rects
t.ok(rect.x > 40 && rect.x + rect.width < 200, 'the match sits inside the line')
t.ok(rect.y < 100 + 24 && rect.y + rect.height > 100, 'the match covers the baseline')
t.is(doc.findText(0, 'pdf', { matchCase: true }).length, 0, 'matchCase is honoured')
t.is(doc.findText(0, 'Hel', { wholeWord: true }).length, 0, 'wholeWord is honoured')
t.exception.all(() => doc.findText(0, ''), /non-empty string/)
doc.close()
})
test('textSpans gives each run of text and where it is', (t) => {
const doc = addon.open(textPdf())
const spans = doc.textSpans(0)
t.is(spans.length, 1)
t.is(spans[0].text, 'Hello PDF')
t.ok(spans[0].x >= 40 && spans[0].x < 45, 'starts where the text starts')
t.alike(addon.open(imagePdf()).textSpans(0), [], 'a page with no text layer has no spans')
doc.close()
})
test('deletePages and movePages change the page order', async (t) => {
const pages = (doc) => [...doc.textPages()].map(({ text }) => text)
const doc = addon.open(twoPageTextPdf())
doc.importPages(addon.open(textPdf()))
t.alike(pages(doc), ['Page One', 'Page Two', 'Hello PDF'])
doc.movePages([2], 0)
t.alike(pages(doc), ['Hello PDF', 'Page One', 'Page Two'])
doc.deletePages([1, 1])
t.alike(pages(doc), ['Hello PDF', 'Page Two'], 'a page listed twice is deleted once')
await doc.movePagesAsync([0], 1)
t.alike(pages(doc), ['Page Two', 'Hello PDF'])
await doc.deletePagesAsync([0])
t.alike(pages(doc), ['Hello PDF'])
t.exception.all(() => doc.deletePages(), /array of page indexes/)
t.exception.all(() => doc.movePages([0], 1), /from 0 to 0/)
doc.close()
})
test('flatten turns form widgets into page content', async (t) => {
const doc = addon.open(formPdf())
t.ok(doc.fields().length > 0)
doc.flatten()
t.alike(doc.fields(), [], 'the widgets are gone')
t.ok(doc.render(0).width > 0, 'the page still renders')
doc.close()
const other = addon.open(formPdf())
await other.flattenAsync({ pages: [0] })
t.alike(other.fields(), [])
other.close()
})
test('fields is empty for a PDF without a form', (t) => {
t.alike(addon.fields(textPdf()), [])
})
test('fillFields sets text, checkbox, radio and dropdown values', (t) => {
const filled = addon.fillFields(formPdf(), {
name: 'Ada Lovelace',
agree: true,
color: 'blue',
size: 'M'
})
const byName = Object.fromEntries(addon.fields(filled).map((field) => [field.name, field]))
t.is(byName.name.value, 'Ada Lovelace')
t.is(byName.agree.value, true)
t.is(byName.color.value, 'blue')
t.is(byName.size.value, 'M')
t.is(byName.id.value, 'A-1', 'untouched fields keep their value')
})
test('fillFields can clear a checkbox and change a radio choice', (t) => {
const first = addon.fillFields(formPdf(), { agree: true, color: 'red' })
const second = addon.fillFields(first, { agree: false, color: 'blue' })
const byName = Object.fromEntries(addon.fields(second).map((field) => [field.name, field]))
t.is(byName.agree.value, false)
t.is(byName.color.value, 'blue')
})
test('fillFields explains bad input', (t) => {
t.exception.all(() => addon.fillFields(formPdf(), { nope: 'x' }), /no field named 'nope'/)
t.exception.all(() => addon.fillFields(formPdf(), { id: 'B-2' }), /read-only/)
t.exception.all(() => addon.fillFields(formPdf(), { color: 'green' }), /radio group/)
t.exception.all(() => addon.fillFields(formPdf(), { size: 'XL' }), /accepts one of/)
t.exception.all(() => addon.fillFields(textPdf(), { name: 'x' }), /no form fields/)
})
test('edit methods throw once the document is closed', (t) => {
const doc = addon.open(textPdf())
doc.close()
t.exception.all(() => doc.save(), /document is closed/)
t.exception.all(() => doc.rotate(90), /document is closed/)
t.exception.all(() => doc.fields(), /document is closed/)
})
test('async edits match their sync forms', async (t) => {
t.alike(
pageTexts(await addon.mergeAsync([textPdf(), twoPageTextPdf()])),
pageTexts(addon.merge([textPdf(), twoPageTextPdf()]))
)
t.alike(
pageTexts(await addon.extractPagesAsync(twoPageTextPdf(), [1, 0])),
pageTexts(addon.extractPages(twoPageTextPdf(), [1, 0]))
)
const rotated = await addon.rotateAsync(sizedPdf(200, 100, 'wide'), 90)
t.alike(addon.open(rotated).pageSize(0), { width: 100, height: 200 })
t.ok(pageTexts(await addon.watermarkAsync(twoPageTextPdf(), 'DRAFT'))[1].includes('DRAFT'))
t.ok(pageTexts(await addon.decryptAsync(lockedPdf('s3cret'), 's3cret'))[0].includes('Secret'))
t.alike(await addon.fieldsAsync(formPdf()), addon.fields(formPdf()))
const values = { name: 'Ada Lovelace', agree: true, color: 'blue', size: 'M' }
t.alike(
addon.fields(await addon.fillFieldsAsync(formPdf(), values)),
addon.fields(addon.fillFields(formPdf(), values))
)
})
test('Doc async edits apply in the order they were called', async (t) => {
const doc = addon.create()
const source = addon.open(twoPageTextPdf())
const steps = [
doc.importPagesAsync(source, { pages: [1] }),
doc.importPagesAsync(source, { pages: [0], at: 0 }),
doc.rotateAsync(90, { pages: [1] }),
doc.watermarkAsync('COPY', { pages: [0] })
]
await Promise.all(steps)
const saved = await doc.saveAsync()
doc.close()
source.close()
const texts = pageTexts(saved)
t.ok(texts[0].includes('Page One') && texts[0].includes('COPY'))
t.ok(texts[1].includes('Page Two') && !texts[1].includes('COPY'))
})
test('fillFieldsAsync returns the filled fields', async (t) => {
const doc = addon.open(formPdf())
const filled = await doc.fillFieldsAsync({ name: 'Grace Hopper', size: 'L' })
t.alike(
filled.map(({ name, value }) => [name, value]),
[
['name', 'Grace Hopper'],
['size', 'L']
]
)
doc.close()
})
test('fillFieldsAsync keeps its place in the queue', async (t) => {
const doc = addon.open(formPdf())
const filling = doc.fillFieldsAsync({ name: 'Ada Lovelace' })
const saved = await doc.saveAsync()
await filling
doc.close()