mercury-agent 0.5.7 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +29 -3
  2. package/container/Dockerfile +9 -5
  3. package/container/Dockerfile.base +2 -2
  4. package/container/build.sh +7 -38
  5. package/docs/authoring-profiles.md +2 -2
  6. package/docs/container-lifecycle.md +7 -11
  7. package/docs/memory.md +0 -6
  8. package/docs/prd-config-load.md +1 -1
  9. package/examples/extensions/napkin/index.ts +17 -10
  10. package/examples/extensions/napkin/prompts/kb-distillation.md +6 -6
  11. package/examples/extensions/pdf/skill/SKILL.md +545 -178
  12. package/examples/extensions/pdf/skill/forms.md +273 -214
  13. package/examples/extensions/pdf/skill/reference.md +450 -480
  14. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +221 -58
  15. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +90 -5
  16. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +138 -22
  17. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +191 -27
  18. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +149 -104
  19. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +215 -91
  20. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +130 -86
  21. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +204 -87
  22. package/examples/extensions/tradestation/skill/SKILL.md +2 -2
  23. package/package.json +17 -1
  24. package/resources/templates/env.template +0 -3
  25. package/src/adapters/whatsapp-media.ts +5 -2
  26. package/src/agent/container-entry.ts +2 -2
  27. package/src/agent/container-runner.ts +19 -14
  28. package/src/bridges/telegram.ts +5 -2
  29. package/src/chat-shim.ts +26 -17
  30. package/src/core/media.ts +5 -2
  31. package/src/dashboard/tokens.css +1 -1
  32. package/src/extensions/image-builder.ts +2 -2
  33. package/src/main.ts +2 -2
  34. package/examples/extensions/pdf/skill/LICENSE.txt +0 -30
@@ -1,176 +1,353 @@
1
1
  ---
2
2
  name: pdf
3
- description: Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.
4
- license: Proprietary. LICENSE.txt has complete terms
3
+ description: Use this skill whenever the user wants to do anything with PDF files -- reading, extracting text/tables, merging, splitting, rotating, watermarking, creating, filling forms, encrypting/decrypting, extracting images, OCR, or converting to images. Activate when a .pdf file is mentioned or PDF output is requested.
5
4
  ---
6
5
 
7
- # PDF Processing Guide
6
+ # PDF Processing Skill
8
7
 
9
- ## Overview
8
+ Process PDF files using open-source tools. This guide is organized by task -- find what you need to do, get working code.
10
9
 
11
- This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions.
10
+ ## Decision Tree -- Pick the Right Tool
12
11
 
13
- ## Quick Start
12
+ ```
13
+ What do you need to do?
14
+ |
15
+ +- READ text from a PDF
16
+ | +- Simple text extraction ----------> pypdf (fast, pure Python)
17
+ | +- Text with layout/positions ------> pdfplumber (preserves spatial info)
18
+ | +- Tables from PDF -----------------> pdfplumber (best table extraction)
19
+ | +- Scanned PDF (image-based) -------> pytesseract + pdf2image (OCR)
20
+ | +- CLI one-liner --------------------> pdftotext (poppler)
21
+ |
22
+ +- CREATE a new PDF from scratch ------> reportlab
23
+ |
24
+ +- MODIFY an existing PDF
25
+ | +- Merge multiple PDFs --------------> pypdf
26
+ | +- Split into pages -----------------> pypdf
27
+ | +- Rotate pages ---------------------> pypdf
28
+ | +- Add watermark --------------------> pypdf (overlay)
29
+ | +- Encrypt / decrypt ----------------> pypdf or qpdf (CLI)
30
+ | +- Fill form fields -----------------> pypdf (fillable) or reportlab (non-fillable)
31
+ |
32
+ +- EXTRACT from a PDF
33
+ | +- Images ---------------------------> pypdfium2 or pdfimages (CLI)
34
+ | +- Metadata -------------------------> pypdf
35
+ |
36
+ +- CONVERT
37
+ +- PDF to images --------------------> pdf2image or pypdfium2
38
+ +- Images to PDF --------------------> reportlab or pypdf
39
+ ```
14
40
 
15
- ```python
16
- from pypdf import PdfReader, PdfWriter
41
+ ## Decision Rules
42
+
43
+ Follow these rules to pick the right approach for ambiguous tasks:
44
+
45
+ 1. **IF the user wants to extract text** -> Use pypdf or pdfplumber first. If the result is empty or garbled, the PDF is likely scanned -> fall back to the OCR workflow (Section 10).
46
+
47
+ 2. **IF the user wants to fill a form** -> First run `check_fillable_fields.py` to detect if the PDF has fillable fields.
48
+ - If fillable fields exist -> use the fillable workflow (pypdf)
49
+ - If no fillable fields -> use the annotation-based workflow (reportlab overlay)
50
+ - See `forms.md` for the complete decision tree.
51
+
52
+ 3. **IF the user wants to merge PDFs** -> Use pypdf for simple merges. Use `qpdf` CLI if you need fine-grained page selection (e.g., specific page ranges from multiple files).
17
53
 
18
- # Read a PDF
19
- reader = PdfReader("document.pdf")
20
- print(f"Pages: {len(reader.pages)}")
54
+ 4. **IF the user wants to encrypt** -> Use pypdf for AES-256 encryption. Use `qpdf` CLI for quick password protection from the command line.
55
+
56
+ 5. **IF the user wants images from a PDF** -> Use pypdfium2 for high-quality page renders. Use `pdfimages` CLI to extract embedded images without re-encoding.
57
+
58
+ 6. **IF the user wants to create a PDF** -> Use reportlab. For simple text documents, use the Canvas API. For complex reports with automatic layout, use Platypus (SimpleDocTemplate + Paragraphs).
59
+
60
+ ## Available Libraries
61
+
62
+ All pre-installed in the container:
63
+
64
+ | Library | Purpose |
65
+ |---|---|
66
+ | `pypdf` | Read, merge, split, rotate, encrypt, fill forms |
67
+ | `pdfplumber` | Extract text with layout, tables |
68
+ | `pypdfium2` | Fast rendering to images, text extraction |
69
+ | `reportlab` | Create PDFs from scratch, draw text/shapes/images |
70
+ | `pytesseract` | OCR (requires tesseract-ocr system package) |
71
+ | `pdf2image` | Convert PDF pages to PIL images (requires poppler) |
72
+ | `Pillow` | Image processing |
73
+
74
+ CLI tools: `qpdf`, `pdftotext`, `pdfimages` (poppler-utils), `tesseract`
75
+
76
+ ---
21
77
 
22
- # Extract text
23
- text = ""
78
+ ## 1. Read / Extract Text
79
+
80
+ ### Simple text extraction with pypdf
81
+
82
+ ```python
83
+ from pypdf import PdfReader
84
+
85
+ reader = PdfReader("input.pdf")
24
86
  for page in reader.pages:
25
- text += page.extract_text()
87
+ text = page.extract_text()
88
+ print(text)
26
89
  ```
27
90
 
28
- ## Python Libraries
91
+ ### Extract text with layout using pdfplumber
29
92
 
30
- ### pypdf - Basic Operations
93
+ ```python
94
+ import pdfplumber
95
+
96
+ with pdfplumber.open("input.pdf") as pdf:
97
+ for page in pdf.pages:
98
+ text = page.extract_text()
99
+ print(text)
100
+ ```
101
+
102
+ ### Extract tables with pdfplumber
31
103
 
32
- #### Merge PDFs
33
104
  ```python
34
- from pypdf import PdfWriter, PdfReader
105
+ import pdfplumber
106
+
107
+ with pdfplumber.open("input.pdf") as pdf:
108
+ for page in pdf.pages:
109
+ tables = page.extract_tables()
110
+ for table in tables:
111
+ for row in table:
112
+ print(row)
113
+ ```
114
+
115
+ Export a table to CSV:
116
+
117
+ ```python
118
+ import pdfplumber
119
+ import csv
120
+
121
+ with pdfplumber.open("input.pdf") as pdf:
122
+ page = pdf.pages[0] # first page
123
+ table = page.extract_tables()[0] # first table
124
+
125
+ with open("output.csv", "w", newline="") as f:
126
+ writer = csv.writer(f)
127
+ writer.writerows(table)
128
+ ```
129
+
130
+ ### CLI text extraction
131
+
132
+ ```bash
133
+ # Plain text
134
+ pdftotext input.pdf output.txt
135
+
136
+ # Preserve layout
137
+ pdftotext -layout input.pdf output.txt
138
+
139
+ # Extract specific pages
140
+ pdftotext -f 2 -l 5 input.pdf output.txt
141
+
142
+ # To stdout
143
+ pdftotext input.pdf -
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 2. Merge PDFs
149
+
150
+ ```python
151
+ from pypdf import PdfWriter
35
152
 
36
153
  writer = PdfWriter()
37
- for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
38
- reader = PdfReader(pdf_file)
39
- for page in reader.pages:
40
- writer.add_page(page)
41
154
 
42
- with open("merged.pdf", "wb") as output:
43
- writer.write(output)
155
+ # Add entire PDFs
156
+ for pdf_path in ["file1.pdf", "file2.pdf", "file3.pdf"]:
157
+ writer.append(pdf_path)
158
+
159
+ writer.write("outbox/merged.pdf")
160
+ writer.close()
44
161
  ```
45
162
 
46
- #### Split PDF
163
+ Merge specific page ranges:
164
+
165
+ ```python
166
+ from pypdf import PdfWriter
167
+
168
+ writer = PdfWriter()
169
+ writer.append("file1.pdf", pages=(0, 5)) # pages 1-5
170
+ writer.append("file2.pdf", pages=(2, 4)) # pages 3-4
171
+ writer.write("outbox/merged.pdf")
172
+ writer.close()
173
+ ```
174
+
175
+ ---
176
+
177
+ ## 3. Split PDFs
178
+
179
+ ### Split into individual pages
180
+
47
181
  ```python
182
+ from pypdf import PdfReader, PdfWriter
183
+
48
184
  reader = PdfReader("input.pdf")
49
185
  for i, page in enumerate(reader.pages):
50
186
  writer = PdfWriter()
51
187
  writer.add_page(page)
52
- with open(f"page_{i+1}.pdf", "wb") as output:
53
- writer.write(output)
188
+ writer.write(f"outbox/page_{i + 1}.pdf")
189
+ writer.close()
54
190
  ```
55
191
 
56
- #### Extract Metadata
192
+ ### Split into chunks
193
+
57
194
  ```python
58
- reader = PdfReader("document.pdf")
59
- meta = reader.metadata
60
- print(f"Title: {meta.title}")
61
- print(f"Author: {meta.author}")
62
- print(f"Subject: {meta.subject}")
63
- print(f"Creator: {meta.creator}")
195
+ from pypdf import PdfReader, PdfWriter
196
+
197
+ reader = PdfReader("input.pdf")
198
+ chunk_size = 10
199
+
200
+ for start in range(0, len(reader.pages), chunk_size):
201
+ writer = PdfWriter()
202
+ end = min(start + chunk_size, len(reader.pages))
203
+ for page in reader.pages[start:end]:
204
+ writer.add_page(page)
205
+ writer.write(f"outbox/chunk_{start // chunk_size + 1}.pdf")
206
+ writer.close()
64
207
  ```
65
208
 
66
- #### Rotate Pages
209
+ ---
210
+
211
+ ## 4. Rotate Pages
212
+
67
213
  ```python
214
+ from pypdf import PdfReader, PdfWriter
215
+
68
216
  reader = PdfReader("input.pdf")
69
217
  writer = PdfWriter()
70
218
 
71
- page = reader.pages[0]
72
- page.rotate(90) # Rotate 90 degrees clockwise
73
- writer.add_page(page)
219
+ for page in reader.pages:
220
+ page.rotate(90) # 90, 180, or 270 degrees clockwise
221
+ writer.add_page(page)
74
222
 
75
- with open("rotated.pdf", "wb") as output:
76
- writer.write(output)
223
+ writer.write("outbox/rotated.pdf")
224
+ writer.close()
77
225
  ```
78
226
 
79
- ### pdfplumber - Text and Table Extraction
227
+ Rotate only specific pages:
80
228
 
81
- #### Extract Text with Layout
82
229
  ```python
83
- import pdfplumber
230
+ from pypdf import PdfReader, PdfWriter
84
231
 
85
- with pdfplumber.open("document.pdf") as pdf:
86
- for page in pdf.pages:
87
- text = page.extract_text()
88
- print(text)
232
+ reader = PdfReader("input.pdf")
233
+ writer = PdfWriter()
234
+
235
+ pages_to_rotate = [0, 2, 4] # 0-indexed
236
+
237
+ for i, page in enumerate(reader.pages):
238
+ if i in pages_to_rotate:
239
+ page.rotate(90)
240
+ writer.add_page(page)
241
+
242
+ writer.write("outbox/rotated.pdf")
243
+ writer.close()
89
244
  ```
90
245
 
91
- #### Extract Tables
246
+ ---
247
+
248
+ ## 5. Add Watermark
249
+
92
250
  ```python
93
- with pdfplumber.open("document.pdf") as pdf:
94
- for i, page in enumerate(pdf.pages):
95
- tables = page.extract_tables()
96
- for j, table in enumerate(tables):
97
- print(f"Table {j+1} on page {i+1}:")
98
- for row in table:
99
- print(row)
251
+ from pypdf import PdfReader, PdfWriter
252
+
253
+ # The watermark must be a single-page PDF (create one with reportlab if needed)
254
+ stamp_reader = PdfReader("watermark.pdf")
255
+ stamp_page = stamp_reader.pages[0]
256
+
257
+ reader = PdfReader("input.pdf")
258
+ writer = PdfWriter()
259
+
260
+ for page in reader.pages:
261
+ page.merge_page(stamp_page)
262
+ writer.add_page(page)
263
+
264
+ writer.write("outbox/watermarked.pdf")
265
+ writer.close()
100
266
  ```
101
267
 
102
- #### Advanced Table Extraction
268
+ ### Create a watermark PDF with reportlab
269
+
103
270
  ```python
104
- import pandas as pd
271
+ from reportlab.pdfgen import canvas
272
+ from reportlab.lib.pagesizes import letter
273
+ from reportlab.lib.colors import Color
105
274
 
106
- with pdfplumber.open("document.pdf") as pdf:
107
- all_tables = []
108
- for page in pdf.pages:
109
- tables = page.extract_tables()
110
- for table in tables:
111
- if table: # Check if table is not empty
112
- df = pd.DataFrame(table[1:], columns=table[0])
113
- all_tables.append(df)
275
+ c = canvas.Canvas("watermark.pdf", pagesize=letter)
276
+ width, height = letter
114
277
 
115
- # Combine all tables
116
- if all_tables:
117
- combined_df = pd.concat(all_tables, ignore_index=True)
118
- combined_df.to_excel("extracted_tables.xlsx", index=False)
278
+ c.saveState()
279
+ c.translate(width / 2, height / 2)
280
+ c.rotate(45)
281
+ c.setFillColor(Color(0.8, 0.8, 0.8, alpha=0.3))
282
+ c.setFont("Helvetica-Bold", 60)
283
+ c.drawCentredString(0, 0, "CONFIDENTIAL")
284
+ c.restoreState()
285
+ c.save()
119
286
  ```
120
287
 
121
- ### reportlab - Create PDFs
288
+ ---
289
+
290
+ ## 6. Create PDFs from Scratch (reportlab)
122
291
 
123
- #### Basic PDF Creation
124
292
  ```python
125
- from reportlab.lib.pagesizes import letter
126
293
  from reportlab.pdfgen import canvas
294
+ from reportlab.lib.pagesizes import letter, A4
295
+ from reportlab.lib.units import inch
127
296
 
128
- c = canvas.Canvas("hello.pdf", pagesize=letter)
297
+ c = canvas.Canvas("outbox/new.pdf", pagesize=letter)
129
298
  width, height = letter
130
299
 
131
- # Add text
132
- c.drawString(100, height - 100, "Hello World!")
133
- c.drawString(100, height - 120, "This is a PDF created with reportlab")
300
+ # Text
301
+ c.setFont("Helvetica-Bold", 24)
302
+ c.drawString(1 * inch, height - 1 * inch, "Report Title")
303
+
304
+ c.setFont("Helvetica", 12)
305
+ c.drawString(1 * inch, height - 1.5 * inch, "Generated automatically.")
306
+
307
+ # Line
308
+ c.setStrokeColorRGB(0, 0, 0)
309
+ c.line(1 * inch, height - 1.7 * inch, width - 1 * inch, height - 1.7 * inch)
310
+
311
+ # Rectangle
312
+ c.setFillColorRGB(0.9, 0.9, 1.0)
313
+ c.rect(1 * inch, height - 4 * inch, 4 * inch, 2 * inch, fill=True)
134
314
 
135
- # Add a line
136
- c.line(100, height - 140, 400, height - 140)
315
+ # New page
316
+ c.showPage()
317
+ c.setFont("Helvetica", 12)
318
+ c.drawString(1 * inch, height - 1 * inch, "Page 2 content here.")
137
319
 
138
- # Save
139
320
  c.save()
140
321
  ```
141
322
 
142
- #### Create PDF with Multiple Pages
323
+ ### Multi-line text with automatic wrapping
324
+
143
325
  ```python
144
326
  from reportlab.lib.pagesizes import letter
145
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
327
+ from reportlab.lib.units import inch
328
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
146
329
  from reportlab.lib.styles import getSampleStyleSheet
147
330
 
148
- doc = SimpleDocTemplate("report.pdf", pagesize=letter)
331
+ doc = SimpleDocTemplate("outbox/report.pdf", pagesize=letter)
149
332
  styles = getSampleStyleSheet()
150
333
  story = []
151
334
 
152
- # Add content
153
- title = Paragraph("Report Title", styles['Title'])
154
- story.append(title)
155
- story.append(Spacer(1, 12))
156
-
157
- body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
158
- story.append(body)
159
- story.append(PageBreak())
335
+ story.append(Paragraph("Report Title", styles["Title"]))
336
+ story.append(Spacer(1, 0.25 * inch))
160
337
 
161
- # Page 2
162
- story.append(Paragraph("Page 2", styles['Heading1']))
163
- story.append(Paragraph("Content for page 2", styles['Normal']))
338
+ body_text = """This is a paragraph that will automatically wrap to fit the page width.
339
+ It supports <b>bold</b>, <i>italic</i>, and other basic HTML-like formatting."""
340
+ story.append(Paragraph(body_text, styles["BodyText"]))
164
341
 
165
- # Build PDF
166
342
  doc.build(story)
167
343
  ```
168
344
 
169
- #### Subscripts and Superscripts
345
+ ### Pitfall: Unicode subscripts and superscripts
170
346
 
171
- **IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes.
347
+ **IMPORTANT**: Never use Unicode subscript/superscript characters (such as the subscript digits) in ReportLab PDFs. The built-in fonts (Helvetica, Times-Roman, Courier) do not include these glyphs, causing them to render as solid black boxes.
172
348
 
173
349
  Instead, use ReportLab's XML markup tags in Paragraph objects:
350
+
174
351
  ```python
175
352
  from reportlab.platypus import Paragraph
176
353
  from reportlab.lib.styles import getSampleStyleSheet
@@ -178,137 +355,327 @@ from reportlab.lib.styles import getSampleStyleSheet
178
355
  styles = getSampleStyleSheet()
179
356
 
180
357
  # Subscripts: use <sub> tag
181
- chemical = Paragraph("H<sub>2</sub>O", styles['Normal'])
358
+ chemical = Paragraph("H<sub>2</sub>O", styles["Normal"])
182
359
 
183
360
  # Superscripts: use <super> tag
184
- squared = Paragraph("x<super>2</super> + y<super>2</super>", styles['Normal'])
361
+ squared = Paragraph("x<super>2</super> + y<super>2</super>", styles["Normal"])
185
362
  ```
186
363
 
187
- For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts.
364
+ For canvas-drawn text (not Paragraph objects), manually adjust the font size and position rather than using Unicode subscript/superscript characters.
188
365
 
189
- ## Command-Line Tools
366
+ ### Pitfall: Unicode text beyond Latin-1
190
367
 
191
- ### pdftotext (poppler-utils)
192
- ```bash
193
- # Extract text
194
- pdftotext input.pdf output.txt
368
+ ReportLab's built-in fonts (Helvetica, Times-Roman, Courier) do NOT support Unicode characters beyond Latin-1. For Unicode text (CJK, Cyrillic, Arabic, mathematical symbols), you must register a TrueType font:
195
369
 
196
- # Extract text preserving layout
197
- pdftotext -layout input.pdf output.txt
370
+ ```python
371
+ from reportlab.pdfbase import pdfmetrics
372
+ from reportlab.pdfbase.ttfonts import TTFont
198
373
 
199
- # Extract specific pages
200
- pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
374
+ pdfmetrics.registerFont(TTFont("DejaVu", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
375
+ c.setFont("DejaVu", 12)
376
+ c.drawString(100, 700, "Unicode text here")
201
377
  ```
202
378
 
203
- ### qpdf
379
+ ---
380
+
381
+ ## 7. Fill PDF Forms
382
+
383
+ This is a complex topic. Read `forms.md` for the full guide.
384
+
385
+ ### Quick: fill a simple fillable PDF
386
+
387
+ ```python
388
+ from pypdf import PdfReader, PdfWriter
389
+
390
+ reader = PdfReader("form.pdf")
391
+ writer = PdfWriter()
392
+ writer.append(reader)
393
+
394
+ # Get field names
395
+ fields = reader.get_fields()
396
+ for name, field in fields.items():
397
+ print(f"Field: {name}, Type: {field.get('/FT')}")
398
+
399
+ # Fill fields
400
+ writer.update_page_form_field_values(
401
+ writer.pages[0],
402
+ {"first_name": "Alice", "last_name": "Smith", "email": "alice@example.com"},
403
+ auto_regenerate=False,
404
+ )
405
+
406
+ writer.write("outbox/filled.pdf")
407
+ writer.close()
408
+ ```
409
+
410
+ ### Helper scripts
411
+
412
+ The `scripts/` directory contains Python helpers for form processing:
413
+
414
+ | Script | Purpose |
415
+ |---|---|
416
+ | `check_fillable_fields.py` | Detect if a PDF has fillable form fields |
417
+ | `extract_form_field_info.py` | Extract field metadata to JSON |
418
+ | `fill_fillable_fields.py` | Fill fillable fields from a JSON mapping |
419
+ | `check_bounding_boxes.py` | Visualize field positions on the PDF |
420
+ | `convert_pdf_to_images.py` | Render PDF pages to images |
421
+ | `create_validation_image.py` | Overlay showing filled values |
422
+ | `extract_form_structure.py` | Extract structure from non-fillable PDFs |
423
+ | `fill_pdf_form_with_annotations.py` | Fill non-fillable PDFs using annotations |
424
+
425
+ Run any script with `python3 scripts/<name>.py --help` for usage.
426
+
427
+ ---
428
+
429
+ ## 8. Encrypt / Decrypt PDFs
430
+
431
+ ### Encrypt with pypdf
432
+
433
+ ```python
434
+ from pypdf import PdfReader, PdfWriter
435
+
436
+ reader = PdfReader("input.pdf")
437
+ writer = PdfWriter()
438
+ writer.append(reader)
439
+
440
+ # User password (to open) + owner password (to edit)
441
+ writer.encrypt(
442
+ user_password="readpass",
443
+ owner_password="ownerpass",
444
+ algorithm="AES-256",
445
+ )
446
+
447
+ writer.write("outbox/encrypted.pdf")
448
+ writer.close()
449
+ ```
450
+
451
+ ### Decrypt with pypdf
452
+
453
+ ```python
454
+ from pypdf import PdfReader, PdfWriter
455
+
456
+ reader = PdfReader("encrypted.pdf")
457
+ if reader.is_encrypted:
458
+ reader.decrypt("readpass")
459
+
460
+ writer = PdfWriter()
461
+ writer.append(reader)
462
+ writer.write("outbox/decrypted.pdf")
463
+ writer.close()
464
+ ```
465
+
466
+ ### CLI with qpdf
467
+
204
468
  ```bash
205
- # Merge PDFs
206
- qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
469
+ # Encrypt
470
+ qpdf --encrypt userpass ownerpass 256 -- input.pdf encrypted.pdf
471
+
472
+ # Decrypt
473
+ qpdf --decrypt --password=ownerpass encrypted.pdf decrypted.pdf
207
474
 
208
- # Split pages
209
- qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
210
- qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
475
+ # Linearize (optimize for web)
476
+ qpdf --linearize input.pdf optimized.pdf
477
+ ```
478
+
479
+ ---
480
+
481
+ ## 9. Extract Images
482
+
483
+ ### With pypdfium2
484
+
485
+ ```python
486
+ import pypdfium2 as pdfium
211
487
 
212
- # Rotate pages
213
- qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
488
+ pdf = pdfium.PdfDocument("input.pdf")
214
489
 
215
- # Remove password
216
- qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
490
+ for page_index in range(len(pdf)):
491
+ page = pdf[page_index]
492
+ for obj_index, obj in enumerate(page.get_objects()):
493
+ if obj.type == pdfium.FPDF_PAGEOBJ_IMAGE:
494
+ bitmap = obj.get_bitmap()
495
+ pil_image = bitmap.to_pil()
496
+ pil_image.save(f"outbox/image_p{page_index + 1}_{obj_index}.png")
217
497
  ```
218
498
 
219
- ### pdftk (if available)
499
+ ### CLI with pdfimages
500
+
220
501
  ```bash
221
- # Merge
222
- pdftk file1.pdf file2.pdf cat output merged.pdf
502
+ # Extract all images as PNG
503
+ pdfimages -png input.pdf outbox/prefix
223
504
 
224
- # Split
225
- pdftk input.pdf burst
505
+ # Extract as original format
506
+ pdfimages -all input.pdf outbox/prefix
226
507
 
227
- # Rotate
228
- pdftk input.pdf rotate 1east output rotated.pdf
508
+ # List images without extracting
509
+ pdfimages -list input.pdf
229
510
  ```
230
511
 
231
- ## Common Tasks
512
+ ---
513
+
514
+ ## 10. OCR Scanned PDFs
515
+
516
+ ### Detection: is this PDF scanned?
517
+
518
+ If text extraction returns empty or garbled results, the PDF is likely scanned. Fall back to OCR.
232
519
 
233
- ### Extract Text from Scanned PDFs
234
520
  ```python
235
- # Requires: pip install pytesseract pdf2image
236
- import pytesseract
237
521
  from pdf2image import convert_from_path
522
+ import pytesseract
238
523
 
239
- # Convert PDF to images
240
- images = convert_from_path('scanned.pdf')
524
+ # Convert PDF pages to images
525
+ images = convert_from_path("scanned.pdf", dpi=300)
241
526
 
242
527
  # OCR each page
243
- text = ""
528
+ full_text = []
244
529
  for i, image in enumerate(images):
245
- text += f"Page {i+1}:\n"
246
- text += pytesseract.image_to_string(image)
247
- text += "\n\n"
530
+ text = pytesseract.image_to_string(image)
531
+ full_text.append(text)
532
+ print(f"--- Page {i + 1} ---")
533
+ print(text)
534
+
535
+ # Save to file
536
+ with open("outbox/ocr_output.txt", "w") as f:
537
+ f.write("\n\n".join(full_text))
538
+ ```
248
539
 
249
- print(text)
540
+ ### OCR to searchable PDF
541
+
542
+ ```python
543
+ from pdf2image import convert_from_path
544
+ import pytesseract
545
+
546
+ images = convert_from_path("scanned.pdf", dpi=300)
547
+
548
+ # Create searchable PDF from first page
549
+ pdf_bytes = pytesseract.image_to_pdf_or_hocr(images[0], extension="pdf")
550
+ with open("outbox/searchable.pdf", "wb") as f:
551
+ f.write(pdf_bytes)
250
552
  ```
251
553
 
252
- ### Add Watermark
554
+ **Tip**: OCR quality depends heavily on image resolution. Always render at 300 DPI minimum. For poor-quality scans, try 600 DPI.
555
+
556
+ ---
557
+
558
+ ## 11. Convert PDF Pages to Images
559
+
560
+ ### With pdf2image (poppler-based)
561
+
253
562
  ```python
254
- from pypdf import PdfReader, PdfWriter
563
+ from pdf2image import convert_from_path
255
564
 
256
- # Create watermark (or load existing)
257
- watermark = PdfReader("watermark.pdf").pages[0]
565
+ # All pages
566
+ images = convert_from_path("input.pdf", dpi=200)
567
+ for i, img in enumerate(images):
568
+ img.save(f"outbox/page_{i + 1}.png", "PNG")
258
569
 
259
- # Apply to all pages
260
- reader = PdfReader("document.pdf")
261
- writer = PdfWriter()
570
+ # Specific pages
571
+ images = convert_from_path("input.pdf", dpi=200, first_page=2, last_page=5)
572
+ ```
262
573
 
263
- for page in reader.pages:
264
- page.merge_page(watermark)
265
- writer.add_page(page)
574
+ ### With pypdfium2 (no poppler needed)
575
+
576
+ ```python
577
+ import pypdfium2 as pdfium
578
+
579
+ pdf = pdfium.PdfDocument("input.pdf")
266
580
 
267
- with open("watermarked.pdf", "wb") as output:
268
- writer.write(output)
581
+ for i in range(len(pdf)):
582
+ page = pdf[i]
583
+ bitmap = page.render(scale=2) # 2x = ~144 DPI
584
+ pil_image = bitmap.to_pil()
585
+ pil_image.save(f"outbox/page_{i + 1}.png")
269
586
  ```
270
587
 
271
- ### Extract Images
272
- ```bash
273
- # Using pdfimages (poppler-utils)
274
- pdfimages -j input.pdf output_prefix
588
+ ### With the helper script
275
589
 
276
- # This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
590
+ ```bash
591
+ python3 scripts/convert_pdf_to_images.py input.pdf -o outbox/ --dpi 200
277
592
  ```
278
593
 
279
- ### Password Protection
594
+ ---
595
+
596
+ ## 12. Get PDF Metadata
597
+
280
598
  ```python
281
- from pypdf import PdfReader, PdfWriter
599
+ from pypdf import PdfReader
282
600
 
283
601
  reader = PdfReader("input.pdf")
284
- writer = PdfWriter()
285
602
 
286
- for page in reader.pages:
287
- writer.add_page(page)
603
+ meta = reader.metadata
604
+ print(f"Title: {meta.title}")
605
+ print(f"Author: {meta.author}")
606
+ print(f"Subject: {meta.subject}")
607
+ print(f"Creator: {meta.creator}")
608
+ print(f"Producer: {meta.producer}")
609
+ print(f"Pages: {len(reader.pages)}")
610
+
611
+ # Page dimensions
612
+ page = reader.pages[0]
613
+ box = page.mediabox
614
+ print(f"Size: {float(box.width)} x {float(box.height)} points")
615
+ ```
616
+
617
+ ---
618
+
619
+ ## Common Patterns
620
+
621
+ ### Process many PDFs in a directory
622
+
623
+ ```python
624
+ from pathlib import Path
625
+ from pypdf import PdfReader
626
+
627
+ pdf_dir = Path("inbox")
628
+ for pdf_path in sorted(pdf_dir.glob("*.pdf")):
629
+ reader = PdfReader(pdf_path)
630
+ text = reader.pages[0].extract_text()
631
+ print(f"{pdf_path.name}: {text[:100]}...")
632
+ ```
633
+
634
+ ### Error handling
635
+
636
+ ```python
637
+ from pypdf import PdfReader
638
+ from pypdf.errors import PdfReadError
639
+
640
+ try:
641
+ reader = PdfReader("input.pdf")
642
+ if reader.is_encrypted:
643
+ reader.decrypt("password")
644
+ text = reader.pages[0].extract_text()
645
+ except PdfReadError as e:
646
+ print(f"Invalid or corrupted PDF: {e}")
647
+ except Exception as e:
648
+ print(f"Error processing PDF: {e}")
649
+ ```
650
+
651
+ ### Working with the outbox
288
652
 
289
- # Add password
290
- writer.encrypt("userpassword", "ownerpassword")
653
+ Always write output files to the `outbox/` directory so users can download them:
291
654
 
292
- with open("encrypted.pdf", "wb") as output:
293
- writer.write(output)
655
+ ```python
656
+ import os
657
+ os.makedirs("outbox", exist_ok=True)
658
+ writer.write("outbox/result.pdf")
294
659
  ```
295
660
 
296
- ## Quick Reference
661
+ ---
662
+
663
+ ## Error Handling Guide
664
+
665
+ When a PDF operation fails, follow these steps:
666
+
667
+ 1. **"Cannot open file" / encrypted error** -> PDF is encrypted. Ask the user for the password, then use `reader.decrypt(password)`.
668
+
669
+ 2. **Empty text extraction** -> PDF is scanned/image-based. Switch to the OCR workflow (Section 10).
670
+
671
+ 3. **"Cannot find font" in ReportLab** -> Register a TrueType font before use. See Section 6 pitfalls.
672
+
673
+ 4. **Corrupted PDF** -> Try `qpdf --check input.pdf` first. Then `qpdf input.pdf --replace-input` to attempt repair.
674
+
675
+ 5. **"No form fields found"** -> PDF uses static layout, not AcroForm. Use the annotation-based filling workflow (see forms.md, non-fillable section).
297
676
 
298
- | Task | Best Tool | Command/Code |
299
- |------|-----------|--------------|
300
- | Merge PDFs | pypdf | `writer.add_page(page)` |
301
- | Split PDFs | pypdf | One page per file |
302
- | Extract text | pdfplumber | `page.extract_text()` |
303
- | Extract tables | pdfplumber | `page.extract_tables()` |
304
- | Create PDFs | reportlab | Canvas or Platypus |
305
- | Command line merge | qpdf | `qpdf --empty --pages ...` |
306
- | OCR scanned PDFs | pytesseract | Convert to image first |
307
- | Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md |
677
+ 6. **Large PDF slow to process** -> Process pages in batches. Use `qpdf --pages . 1-50 --` to extract chunks. Use pypdfium2 instead of pdf2image for rendering -- it is significantly faster.
308
678
 
309
- ## Next Steps
679
+ 7. **Image extraction returns 0 images** -> Images may be in-lined in content streams. Try `pdfimages -all` CLI tool instead.
310
680
 
311
- - For advanced pypdfium2 usage, see REFERENCE.md
312
- - For JavaScript libraries (pdf-lib), see REFERENCE.md
313
- - If you need to fill out a PDF form, follow the instructions in FORMS.md
314
- - For troubleshooting guides, see REFERENCE.md
681
+ 8. **Field values not visible after filling** -> Set `auto_regenerate=False` when calling `update_page_form_field_values`. Some PDF viewers cache field appearances. Try flattening: `qpdf --flatten-annotations=all filled.pdf output.pdf`.