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.
- package/README.md +29 -3
- package/container/Dockerfile +9 -5
- package/container/Dockerfile.base +2 -2
- package/container/build.sh +7 -38
- package/docs/authoring-profiles.md +2 -2
- package/docs/container-lifecycle.md +7 -11
- package/docs/memory.md +0 -6
- package/docs/prd-config-load.md +1 -1
- package/examples/extensions/napkin/index.ts +17 -10
- package/examples/extensions/napkin/prompts/kb-distillation.md +6 -6
- package/examples/extensions/pdf/skill/SKILL.md +545 -178
- package/examples/extensions/pdf/skill/forms.md +273 -214
- package/examples/extensions/pdf/skill/reference.md +450 -480
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +221 -58
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +90 -5
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +138 -22
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +191 -27
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +149 -104
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +215 -91
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +130 -86
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +204 -87
- package/examples/extensions/tradestation/skill/SKILL.md +2 -2
- package/package.json +17 -1
- package/resources/templates/env.template +0 -3
- package/src/adapters/whatsapp-media.ts +5 -2
- package/src/agent/container-entry.ts +2 -2
- package/src/agent/container-runner.ts +19 -14
- package/src/bridges/telegram.ts +5 -2
- package/src/chat-shim.ts +26 -17
- package/src/core/media.ts +5 -2
- package/src/dashboard/tokens.css +1 -1
- package/src/extensions/image-builder.ts +2 -2
- package/src/main.ts +2 -2
- 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
|
|
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
|
|
6
|
+
# PDF Processing Skill
|
|
8
7
|
|
|
9
|
-
|
|
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
|
-
|
|
10
|
+
## Decision Tree -- Pick the Right Tool
|
|
12
11
|
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
87
|
+
text = page.extract_text()
|
|
88
|
+
print(text)
|
|
26
89
|
```
|
|
27
90
|
|
|
28
|
-
|
|
91
|
+
### Extract text with layout using pdfplumber
|
|
29
92
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
188
|
+
writer.write(f"outbox/page_{i + 1}.pdf")
|
|
189
|
+
writer.close()
|
|
54
190
|
```
|
|
55
191
|
|
|
56
|
-
|
|
192
|
+
### Split into chunks
|
|
193
|
+
|
|
57
194
|
```python
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
page.rotate(90)
|
|
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
|
-
|
|
76
|
-
|
|
223
|
+
writer.write("outbox/rotated.pdf")
|
|
224
|
+
writer.close()
|
|
77
225
|
```
|
|
78
226
|
|
|
79
|
-
|
|
227
|
+
Rotate only specific pages:
|
|
80
228
|
|
|
81
|
-
#### Extract Text with Layout
|
|
82
229
|
```python
|
|
83
|
-
import
|
|
230
|
+
from pypdf import PdfReader, PdfWriter
|
|
84
231
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## 5. Add Watermark
|
|
249
|
+
|
|
92
250
|
```python
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
268
|
+
### Create a watermark PDF with reportlab
|
|
269
|
+
|
|
103
270
|
```python
|
|
104
|
-
|
|
271
|
+
from reportlab.pdfgen import canvas
|
|
272
|
+
from reportlab.lib.pagesizes import letter
|
|
273
|
+
from reportlab.lib.colors import Color
|
|
105
274
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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("
|
|
297
|
+
c = canvas.Canvas("outbox/new.pdf", pagesize=letter)
|
|
129
298
|
width, height = letter
|
|
130
299
|
|
|
131
|
-
#
|
|
132
|
-
c.
|
|
133
|
-
c.drawString(
|
|
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
|
-
#
|
|
136
|
-
c.
|
|
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
|
-
|
|
323
|
+
### Multi-line text with automatic wrapping
|
|
324
|
+
|
|
143
325
|
```python
|
|
144
326
|
from reportlab.lib.pagesizes import letter
|
|
145
|
-
from reportlab.
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
story.append(Paragraph(
|
|
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
|
-
|
|
345
|
+
### Pitfall: Unicode subscripts and superscripts
|
|
170
346
|
|
|
171
|
-
**IMPORTANT**: Never use Unicode subscript/superscript characters (
|
|
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[
|
|
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[
|
|
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
|
|
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
|
-
|
|
366
|
+
### Pitfall: Unicode text beyond Latin-1
|
|
190
367
|
|
|
191
|
-
|
|
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
|
-
|
|
197
|
-
|
|
370
|
+
```python
|
|
371
|
+
from reportlab.pdfbase import pdfmetrics
|
|
372
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
198
373
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
206
|
-
qpdf --
|
|
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
|
-
#
|
|
209
|
-
qpdf input.pdf
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
|
|
488
|
+
pdf = pdfium.PdfDocument("input.pdf")
|
|
214
489
|
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
###
|
|
499
|
+
### CLI with pdfimages
|
|
500
|
+
|
|
220
501
|
```bash
|
|
221
|
-
#
|
|
222
|
-
|
|
502
|
+
# Extract all images as PNG
|
|
503
|
+
pdfimages -png input.pdf outbox/prefix
|
|
223
504
|
|
|
224
|
-
#
|
|
225
|
-
|
|
505
|
+
# Extract as original format
|
|
506
|
+
pdfimages -all input.pdf outbox/prefix
|
|
226
507
|
|
|
227
|
-
#
|
|
228
|
-
|
|
508
|
+
# List images without extracting
|
|
509
|
+
pdfimages -list input.pdf
|
|
229
510
|
```
|
|
230
511
|
|
|
231
|
-
|
|
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(
|
|
524
|
+
# Convert PDF pages to images
|
|
525
|
+
images = convert_from_path("scanned.pdf", dpi=300)
|
|
241
526
|
|
|
242
527
|
# OCR each page
|
|
243
|
-
|
|
528
|
+
full_text = []
|
|
244
529
|
for i, image in enumerate(images):
|
|
245
|
-
text
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
563
|
+
from pdf2image import convert_from_path
|
|
255
564
|
|
|
256
|
-
#
|
|
257
|
-
|
|
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
|
-
#
|
|
260
|
-
|
|
261
|
-
|
|
570
|
+
# Specific pages
|
|
571
|
+
images = convert_from_path("input.pdf", dpi=200, first_page=2, last_page=5)
|
|
572
|
+
```
|
|
262
573
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
574
|
+
### With pypdfium2 (no poppler needed)
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
import pypdfium2 as pdfium
|
|
578
|
+
|
|
579
|
+
pdf = pdfium.PdfDocument("input.pdf")
|
|
266
580
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
###
|
|
272
|
-
```bash
|
|
273
|
-
# Using pdfimages (poppler-utils)
|
|
274
|
-
pdfimages -j input.pdf output_prefix
|
|
588
|
+
### With the helper script
|
|
275
589
|
|
|
276
|
-
|
|
590
|
+
```bash
|
|
591
|
+
python3 scripts/convert_pdf_to_images.py input.pdf -o outbox/ --dpi 200
|
|
277
592
|
```
|
|
278
593
|
|
|
279
|
-
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## 12. Get PDF Metadata
|
|
597
|
+
|
|
280
598
|
```python
|
|
281
|
-
from pypdf import PdfReader
|
|
599
|
+
from pypdf import PdfReader
|
|
282
600
|
|
|
283
601
|
reader = PdfReader("input.pdf")
|
|
284
|
-
writer = PdfWriter()
|
|
285
602
|
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
writer.encrypt("userpassword", "ownerpassword")
|
|
653
|
+
Always write output files to the `outbox/` directory so users can download them:
|
|
291
654
|
|
|
292
|
-
|
|
293
|
-
|
|
655
|
+
```python
|
|
656
|
+
import os
|
|
657
|
+
os.makedirs("outbox", exist_ok=True)
|
|
658
|
+
writer.write("outbox/result.pdf")
|
|
294
659
|
```
|
|
295
660
|
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
679
|
+
7. **Image extraction returns 0 images** -> Images may be in-lined in content streams. Try `pdfimages -all` CLI tool instead.
|
|
310
680
|
|
|
311
|
-
|
|
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`.
|