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,612 +1,582 @@
|
|
|
1
|
-
# PDF
|
|
1
|
+
# PDF Advanced Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Extended reference for less common operations, advanced techniques, and CLI tool details.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## pypdf -- Advanced Usage
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement.
|
|
7
|
+
### Cloning with modifications
|
|
9
8
|
|
|
10
|
-
### Render PDF to Images
|
|
11
9
|
```python
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# Process multiple pages
|
|
30
|
-
for i, page in enumerate(pdf):
|
|
31
|
-
bitmap = page.render(scale=1.5)
|
|
32
|
-
img = bitmap.to_pil()
|
|
33
|
-
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)
|
|
10
|
+
from pypdf import PdfReader, PdfWriter
|
|
11
|
+
|
|
12
|
+
reader = PdfReader("input.pdf")
|
|
13
|
+
writer = PdfWriter()
|
|
14
|
+
|
|
15
|
+
# Clone all pages
|
|
16
|
+
writer.clone_document_from_reader(reader)
|
|
17
|
+
|
|
18
|
+
# Modify metadata
|
|
19
|
+
writer.add_metadata({
|
|
20
|
+
"/Author": "Mercury Agent",
|
|
21
|
+
"/Title": "Processed Document",
|
|
22
|
+
"/Subject": "Automated processing",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
writer.write("outbox/output.pdf")
|
|
26
|
+
writer.close()
|
|
34
27
|
```
|
|
35
28
|
|
|
36
|
-
###
|
|
29
|
+
### Cropping pages
|
|
30
|
+
|
|
37
31
|
```python
|
|
38
|
-
import
|
|
32
|
+
from pypdf import PdfReader, PdfWriter
|
|
33
|
+
from pypdf.generic import RectangleObject
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
reader = PdfReader("input.pdf")
|
|
36
|
+
writer = PdfWriter()
|
|
37
|
+
|
|
38
|
+
page = reader.pages[0]
|
|
39
|
+
|
|
40
|
+
# Crop to a specific region (in points, from bottom-left)
|
|
41
|
+
# RectangleObject(left, bottom, right, top)
|
|
42
|
+
page.cropbox = RectangleObject([72, 72, 400, 700])
|
|
43
|
+
|
|
44
|
+
writer.add_page(page)
|
|
45
|
+
writer.write("outbox/cropped.pdf")
|
|
46
|
+
writer.close()
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
### Extracting links and annotations
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
```python
|
|
52
|
+
from pypdf import PdfReader
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
reader = PdfReader("input.pdf")
|
|
55
|
+
for page in reader.pages:
|
|
56
|
+
if "/Annots" in page:
|
|
57
|
+
for annot in page["/Annots"]:
|
|
58
|
+
obj = annot.get_object()
|
|
59
|
+
if obj.get("/Subtype") == "/Link":
|
|
60
|
+
action = obj.get("/A")
|
|
61
|
+
if action and "/URI" in action:
|
|
62
|
+
print(f"URL: {action['/URI']}")
|
|
63
|
+
```
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
```javascript
|
|
54
|
-
import { PDFDocument } from 'pdf-lib';
|
|
55
|
-
import fs from 'fs';
|
|
65
|
+
### Removing pages
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const existingPdfBytes = fs.readFileSync('input.pdf');
|
|
60
|
-
const pdfDoc = await PDFDocument.load(existingPdfBytes);
|
|
67
|
+
```python
|
|
68
|
+
from pypdf import PdfReader, PdfWriter
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
console.log(`Document has ${pageCount} pages`);
|
|
70
|
+
reader = PdfReader("input.pdf")
|
|
71
|
+
writer = PdfWriter()
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
const newPage = pdfDoc.addPage([600, 400]);
|
|
68
|
-
newPage.drawText('Added by pdf-lib', {
|
|
69
|
-
x: 100,
|
|
70
|
-
y: 300,
|
|
71
|
-
size: 16
|
|
72
|
-
});
|
|
73
|
+
pages_to_remove = {2, 5, 7} # 0-indexed
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
```
|
|
75
|
+
for i, page in enumerate(reader.pages):
|
|
76
|
+
if i not in pages_to_remove:
|
|
77
|
+
writer.add_page(page)
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
|
83
|
-
import fs from 'fs';
|
|
84
|
-
|
|
85
|
-
async function createPDF() {
|
|
86
|
-
const pdfDoc = await PDFDocument.create();
|
|
87
|
-
|
|
88
|
-
// Add fonts
|
|
89
|
-
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
90
|
-
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
91
|
-
|
|
92
|
-
// Add page
|
|
93
|
-
const page = pdfDoc.addPage([595, 842]); // A4 size
|
|
94
|
-
const { width, height } = page.getSize();
|
|
95
|
-
|
|
96
|
-
// Add text with styling
|
|
97
|
-
page.drawText('Invoice #12345', {
|
|
98
|
-
x: 50,
|
|
99
|
-
y: height - 50,
|
|
100
|
-
size: 18,
|
|
101
|
-
font: helveticaBold,
|
|
102
|
-
color: rgb(0.2, 0.2, 0.8)
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Add rectangle (header background)
|
|
106
|
-
page.drawRectangle({
|
|
107
|
-
x: 40,
|
|
108
|
-
y: height - 100,
|
|
109
|
-
width: width - 80,
|
|
110
|
-
height: 30,
|
|
111
|
-
color: rgb(0.9, 0.9, 0.9)
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Add table-like content
|
|
115
|
-
const items = [
|
|
116
|
-
['Item', 'Qty', 'Price', 'Total'],
|
|
117
|
-
['Widget', '2', '$50', '$100'],
|
|
118
|
-
['Gadget', '1', '$75', '$75']
|
|
119
|
-
];
|
|
120
|
-
|
|
121
|
-
let yPos = height - 150;
|
|
122
|
-
items.forEach(row => {
|
|
123
|
-
let xPos = 50;
|
|
124
|
-
row.forEach(cell => {
|
|
125
|
-
page.drawText(cell, {
|
|
126
|
-
x: xPos,
|
|
127
|
-
y: yPos,
|
|
128
|
-
size: 12,
|
|
129
|
-
font: helveticaFont
|
|
130
|
-
});
|
|
131
|
-
xPos += 120;
|
|
132
|
-
});
|
|
133
|
-
yPos -= 25;
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const pdfBytes = await pdfDoc.save();
|
|
137
|
-
fs.writeFileSync('created.pdf', pdfBytes);
|
|
138
|
-
}
|
|
79
|
+
writer.write("outbox/trimmed.pdf")
|
|
80
|
+
writer.close()
|
|
139
81
|
```
|
|
140
82
|
|
|
141
|
-
|
|
142
|
-
```javascript
|
|
143
|
-
import { PDFDocument } from 'pdf-lib';
|
|
144
|
-
import fs from 'fs';
|
|
83
|
+
### Overlay / Stamp (Multi-Layer Merge)
|
|
145
84
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const mergedPdf = await PDFDocument.create();
|
|
85
|
+
```python
|
|
86
|
+
from pypdf import PdfReader, PdfWriter
|
|
149
87
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
88
|
+
base = PdfReader("base.pdf")
|
|
89
|
+
stamp = PdfReader("stamp.pdf")
|
|
90
|
+
writer = PdfWriter()
|
|
153
91
|
|
|
154
|
-
|
|
155
|
-
|
|
92
|
+
for page in base.pages:
|
|
93
|
+
page.merge_page(stamp.pages[0]) # Stamp page 1 onto every page
|
|
94
|
+
writer.add_page(page)
|
|
156
95
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
96
|
+
writer.write("outbox/stamped.pdf")
|
|
97
|
+
writer.close()
|
|
98
|
+
```
|
|
160
99
|
|
|
161
|
-
|
|
162
|
-
const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]);
|
|
163
|
-
pdf2Pages.forEach(page => mergedPdf.addPage(page));
|
|
100
|
+
---
|
|
164
101
|
|
|
165
|
-
|
|
166
|
-
fs.writeFileSync('merged.pdf', mergedPdfBytes);
|
|
167
|
-
}
|
|
168
|
-
```
|
|
102
|
+
## pdfplumber -- Advanced Usage
|
|
169
103
|
|
|
170
|
-
###
|
|
104
|
+
### Extracting text from specific regions
|
|
171
105
|
|
|
172
|
-
|
|
106
|
+
```python
|
|
107
|
+
import pdfplumber
|
|
173
108
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
import * as pdfjsLib from 'pdfjs-dist';
|
|
109
|
+
with pdfplumber.open("input.pdf") as pdf:
|
|
110
|
+
page = pdf.pages[0]
|
|
177
111
|
|
|
178
|
-
|
|
179
|
-
|
|
112
|
+
# Crop to a region (x0, top, x1, bottom) -- note: top-left origin
|
|
113
|
+
region = page.within_bbox((50, 100, 400, 300))
|
|
114
|
+
text = region.extract_text()
|
|
115
|
+
print(text)
|
|
116
|
+
```
|
|
180
117
|
|
|
181
|
-
|
|
182
|
-
// Load PDF
|
|
183
|
-
const loadingTask = pdfjsLib.getDocument('document.pdf');
|
|
184
|
-
const pdf = await loadingTask.promise;
|
|
118
|
+
### Table extraction with custom settings
|
|
185
119
|
|
|
186
|
-
|
|
120
|
+
```python
|
|
121
|
+
import pdfplumber
|
|
187
122
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const viewport = page.getViewport({ scale: 1.5 });
|
|
123
|
+
with pdfplumber.open("input.pdf") as pdf:
|
|
124
|
+
page = pdf.pages[0]
|
|
191
125
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
126
|
+
table_settings = {
|
|
127
|
+
"vertical_strategy": "text",
|
|
128
|
+
"horizontal_strategy": "lines",
|
|
129
|
+
"min_words_vertical": 3,
|
|
130
|
+
"min_words_horizontal": 1,
|
|
131
|
+
}
|
|
197
132
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
133
|
+
tables = page.extract_tables(table_settings)
|
|
134
|
+
for table in tables:
|
|
135
|
+
for row in table:
|
|
136
|
+
print(row)
|
|
137
|
+
```
|
|
202
138
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
139
|
+
### Getting character-level data
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import pdfplumber
|
|
143
|
+
|
|
144
|
+
with pdfplumber.open("input.pdf") as pdf:
|
|
145
|
+
page = pdf.pages[0]
|
|
146
|
+
|
|
147
|
+
for char in page.chars:
|
|
148
|
+
print(f"'{char['text']}' at ({char['x0']:.1f}, {char['top']:.1f}) "
|
|
149
|
+
f"font={char['fontname']} size={char['size']:.1f}")
|
|
206
150
|
```
|
|
207
151
|
|
|
208
|
-
|
|
209
|
-
```javascript
|
|
210
|
-
import * as pdfjsLib from 'pdfjs-dist';
|
|
152
|
+
---
|
|
211
153
|
|
|
212
|
-
|
|
213
|
-
const loadingTask = pdfjsLib.getDocument('document.pdf');
|
|
214
|
-
const pdf = await loadingTask.promise;
|
|
154
|
+
## reportlab -- Advanced Usage
|
|
215
155
|
|
|
216
|
-
|
|
156
|
+
### Drawing tables
|
|
217
157
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
158
|
+
```python
|
|
159
|
+
from reportlab.lib.pagesizes import letter
|
|
160
|
+
from reportlab.lib.units import inch
|
|
161
|
+
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
|
|
162
|
+
from reportlab.lib import colors
|
|
222
163
|
|
|
223
|
-
|
|
224
|
-
.map(item => item.str)
|
|
225
|
-
.join(' ');
|
|
164
|
+
doc = SimpleDocTemplate("outbox/table.pdf", pagesize=letter)
|
|
226
165
|
|
|
227
|
-
|
|
166
|
+
data = [
|
|
167
|
+
["Name", "Age", "City"],
|
|
168
|
+
["Alice", "30", "Toronto"],
|
|
169
|
+
["Bob", "25", "Vancouver"],
|
|
170
|
+
["Carol", "35", "Montreal"],
|
|
171
|
+
]
|
|
228
172
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
173
|
+
table = Table(data, colWidths=[2 * inch, 1 * inch, 2 * inch])
|
|
174
|
+
table.setStyle(TableStyle([
|
|
175
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4472C4")),
|
|
176
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
177
|
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
178
|
+
("FONTSIZE", (0, 0), (-1, -1), 11),
|
|
179
|
+
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
|
180
|
+
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
|
181
|
+
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#D9E2F3")]),
|
|
182
|
+
]))
|
|
238
183
|
|
|
239
|
-
|
|
240
|
-
return fullText;
|
|
241
|
-
}
|
|
184
|
+
doc.build([table])
|
|
242
185
|
```
|
|
243
186
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
187
|
+
### Adding page numbers and headers
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from reportlab.lib.pagesizes import letter
|
|
191
|
+
from reportlab.lib.units import inch
|
|
192
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
|
|
193
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
247
194
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
195
|
+
def add_page_number(canvas_obj, doc):
|
|
196
|
+
canvas_obj.saveState()
|
|
197
|
+
canvas_obj.setFont("Helvetica", 9)
|
|
198
|
+
page_num = canvas_obj.getPageNumber()
|
|
199
|
+
text = f"Page {page_num}"
|
|
200
|
+
canvas_obj.drawRightString(7.5 * inch, 0.5 * inch, text)
|
|
201
|
+
# Header
|
|
202
|
+
canvas_obj.drawString(1 * inch, 10.5 * inch, "My Report")
|
|
203
|
+
canvas_obj.line(1 * inch, 10.45 * inch, 7.5 * inch, 10.45 * inch)
|
|
204
|
+
canvas_obj.restoreState()
|
|
205
|
+
|
|
206
|
+
doc = SimpleDocTemplate("outbox/report.pdf", pagesize=letter)
|
|
207
|
+
styles = getSampleStyleSheet()
|
|
208
|
+
story = []
|
|
251
209
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
210
|
+
for i in range(5):
|
|
211
|
+
story.append(Paragraph(f"Content for page {i + 1}", styles["BodyText"]))
|
|
212
|
+
story.append(PageBreak())
|
|
255
213
|
|
|
256
|
-
|
|
257
|
-
console.log(`Annotation type: ${annotation.subtype}`);
|
|
258
|
-
console.log(`Content: ${annotation.contents}`);
|
|
259
|
-
console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`);
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
}
|
|
214
|
+
doc.build(story, onFirstPage=add_page_number, onLaterPages=add_page_number)
|
|
263
215
|
```
|
|
264
216
|
|
|
265
|
-
|
|
217
|
+
### Drawing shapes and diagrams
|
|
266
218
|
|
|
267
|
-
|
|
219
|
+
```python
|
|
220
|
+
from reportlab.pdfgen import canvas
|
|
221
|
+
from reportlab.lib.pagesizes import letter
|
|
222
|
+
from reportlab.lib.units import inch
|
|
223
|
+
from reportlab.lib.colors import HexColor
|
|
268
224
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
225
|
+
c = canvas.Canvas("outbox/shapes.pdf", pagesize=letter)
|
|
226
|
+
w, h = letter
|
|
227
|
+
|
|
228
|
+
# Rounded rectangle
|
|
229
|
+
c.setFillColor(HexColor("#E8F0FE"))
|
|
230
|
+
c.setStrokeColor(HexColor("#4472C4"))
|
|
231
|
+
c.roundRect(1 * inch, h - 3 * inch, 3 * inch, 1.5 * inch, 10, fill=True)
|
|
273
232
|
|
|
274
|
-
#
|
|
233
|
+
# Circle
|
|
234
|
+
c.setFillColor(HexColor("#FCE4EC"))
|
|
235
|
+
c.circle(5.5 * inch, h - 2.25 * inch, 0.75 * inch, fill=True)
|
|
236
|
+
|
|
237
|
+
# Arrow (as a line with a polygon head)
|
|
238
|
+
c.setStrokeColor(HexColor("#333333"))
|
|
239
|
+
c.setLineWidth(2)
|
|
240
|
+
c.line(4.2 * inch, h - 2.25 * inch, 4.6 * inch, h - 2.25 * inch)
|
|
241
|
+
|
|
242
|
+
c.save()
|
|
275
243
|
```
|
|
276
244
|
|
|
277
|
-
|
|
278
|
-
```bash
|
|
279
|
-
# Convert to PNG images with specific resolution
|
|
280
|
-
pdftoppm -png -r 300 document.pdf output_prefix
|
|
245
|
+
---
|
|
281
246
|
|
|
282
|
-
|
|
283
|
-
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages
|
|
247
|
+
## Advanced Techniques
|
|
284
248
|
|
|
285
|
-
|
|
286
|
-
|
|
249
|
+
### Redaction (requires pymupdf)
|
|
250
|
+
|
|
251
|
+
To permanently remove content from a PDF, pymupdf (fitz) provides a redaction API. Install with `pip install pymupdf`.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
import fitz
|
|
255
|
+
|
|
256
|
+
doc = fitz.open("input.pdf")
|
|
257
|
+
for page in doc:
|
|
258
|
+
for rect in page.search_for("CONFIDENTIAL"):
|
|
259
|
+
page.add_redact_annot(rect, fill=(0, 0, 0))
|
|
260
|
+
page.apply_redactions()
|
|
261
|
+
doc.save("outbox/redacted.pdf")
|
|
262
|
+
doc.close()
|
|
287
263
|
```
|
|
288
264
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
265
|
+
**Warning**: `apply_redactions()` permanently removes the underlying content. This is irreversible. Always work on a copy.
|
|
266
|
+
|
|
267
|
+
### Adding Bookmarks / Table of Contents (requires pymupdf)
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
import fitz
|
|
271
|
+
|
|
272
|
+
doc = fitz.open("input.pdf")
|
|
273
|
+
|
|
274
|
+
# Read existing TOC
|
|
275
|
+
toc = doc.get_toc() # list of [level, title, page_number]
|
|
276
|
+
|
|
277
|
+
# Set new TOC
|
|
278
|
+
new_toc = [
|
|
279
|
+
[1, "Chapter 1", 1],
|
|
280
|
+
[2, "Section 1.1", 1],
|
|
281
|
+
[2, "Section 1.2", 3],
|
|
282
|
+
[1, "Chapter 2", 5],
|
|
283
|
+
]
|
|
284
|
+
doc.set_toc(new_toc)
|
|
293
285
|
|
|
294
|
-
|
|
295
|
-
|
|
286
|
+
doc.save("outbox/with_bookmarks.pdf")
|
|
287
|
+
doc.close()
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Text Search and Highlight (requires pymupdf)
|
|
296
291
|
|
|
297
|
-
|
|
298
|
-
|
|
292
|
+
```python
|
|
293
|
+
import fitz
|
|
294
|
+
|
|
295
|
+
doc = fitz.open("input.pdf")
|
|
296
|
+
for page in doc:
|
|
297
|
+
matches = page.search_for("search term")
|
|
298
|
+
for rect in matches:
|
|
299
|
+
page.add_highlight_annot(rect)
|
|
300
|
+
doc.save("outbox/highlighted.pdf")
|
|
301
|
+
doc.close()
|
|
299
302
|
```
|
|
300
303
|
|
|
301
|
-
###
|
|
304
|
+
### Creating Searchable PDFs from Scans
|
|
305
|
+
|
|
306
|
+
For multi-page scanned PDFs, use `ocrmypdf` (install with `pip install ocrmypdf`):
|
|
302
307
|
|
|
303
|
-
#### Complex Page Manipulation
|
|
304
308
|
```bash
|
|
305
|
-
|
|
306
|
-
|
|
309
|
+
ocrmypdf scanned.pdf searchable.pdf
|
|
310
|
+
```
|
|
307
311
|
|
|
308
|
-
|
|
309
|
-
qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf
|
|
312
|
+
For single pages, use pytesseract directly:
|
|
310
313
|
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
```python
|
|
315
|
+
from pdf2image import convert_from_path
|
|
316
|
+
import pytesseract
|
|
317
|
+
|
|
318
|
+
images = convert_from_path("scanned.pdf", dpi=300)
|
|
319
|
+
pdf_bytes = pytesseract.image_to_pdf_or_hocr(images[0], extension="pdf")
|
|
320
|
+
with open("outbox/searchable_page.pdf", "wb") as f:
|
|
321
|
+
f.write(pdf_bytes)
|
|
313
322
|
```
|
|
314
323
|
|
|
315
|
-
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## CLI Tools Reference
|
|
327
|
+
|
|
328
|
+
### qpdf
|
|
329
|
+
|
|
316
330
|
```bash
|
|
317
|
-
#
|
|
318
|
-
qpdf --
|
|
331
|
+
# Merge
|
|
332
|
+
qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
|
|
333
|
+
|
|
334
|
+
# Split every page
|
|
335
|
+
qpdf input.pdf --split-pages output_%d.pdf
|
|
336
|
+
|
|
337
|
+
# Extract pages 3-7
|
|
338
|
+
qpdf input.pdf --pages . 3-7 -- extracted.pdf
|
|
339
|
+
|
|
340
|
+
# Extract complex ranges
|
|
341
|
+
qpdf input.pdf --pages . 1,3-5,8,10-end -- extracted.pdf
|
|
319
342
|
|
|
320
|
-
#
|
|
321
|
-
qpdf
|
|
343
|
+
# Rotate page 1 by 90 degrees
|
|
344
|
+
qpdf input.pdf --rotate=+90:1 rotated.pdf
|
|
322
345
|
|
|
323
|
-
#
|
|
346
|
+
# Rotate all pages
|
|
347
|
+
qpdf input.pdf --rotate=+90 -- rotated.pdf
|
|
348
|
+
|
|
349
|
+
# Linearize for fast web display
|
|
350
|
+
qpdf --linearize input.pdf optimized.pdf
|
|
351
|
+
|
|
352
|
+
# Check/repair
|
|
324
353
|
qpdf --check input.pdf
|
|
325
|
-
qpdf --
|
|
354
|
+
qpdf input.pdf --replace-input # repair in place
|
|
326
355
|
|
|
327
|
-
#
|
|
328
|
-
qpdf --
|
|
329
|
-
```
|
|
356
|
+
# Encrypt AES-256
|
|
357
|
+
qpdf --encrypt userpass ownerpass 256 -- input.pdf encrypted.pdf
|
|
330
358
|
|
|
331
|
-
|
|
332
|
-
```bash
|
|
333
|
-
# Add password protection with specific permissions
|
|
359
|
+
# Encrypt with restricted permissions
|
|
334
360
|
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
|
|
335
361
|
|
|
362
|
+
# Decrypt
|
|
363
|
+
qpdf --decrypt --password=pass encrypted.pdf decrypted.pdf
|
|
364
|
+
|
|
336
365
|
# Check encryption status
|
|
337
366
|
qpdf --show-encryption encrypted.pdf
|
|
338
367
|
|
|
339
|
-
#
|
|
340
|
-
qpdf --
|
|
368
|
+
# Flatten annotations (bake form values)
|
|
369
|
+
qpdf --flatten-annotations=all input.pdf flattened.pdf
|
|
370
|
+
|
|
371
|
+
# Remove restrictions (if no encryption)
|
|
372
|
+
qpdf --decrypt input.pdf unrestricted.pdf
|
|
341
373
|
```
|
|
342
374
|
|
|
343
|
-
|
|
375
|
+
### pdftotext (poppler)
|
|
344
376
|
|
|
345
|
-
|
|
377
|
+
```bash
|
|
378
|
+
# Basic extraction
|
|
379
|
+
pdftotext input.pdf output.txt
|
|
346
380
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
import pdfplumber
|
|
381
|
+
# Preserve layout
|
|
382
|
+
pdftotext -layout input.pdf output.txt
|
|
350
383
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
# Extract all text with coordinates
|
|
355
|
-
chars = page.chars
|
|
356
|
-
for char in chars[:10]: # First 10 characters
|
|
357
|
-
print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
|
|
358
|
-
|
|
359
|
-
# Extract text by bounding box (left, top, right, bottom)
|
|
360
|
-
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
|
|
361
|
-
```
|
|
384
|
+
# Page range
|
|
385
|
+
pdftotext -f 2 -l 10 input.pdf output.txt
|
|
362
386
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
import pdfplumber
|
|
366
|
-
import pandas as pd
|
|
387
|
+
# Output to stdout
|
|
388
|
+
pdftotext input.pdf -
|
|
367
389
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
# Extract tables with custom settings for complex layouts
|
|
372
|
-
table_settings = {
|
|
373
|
-
"vertical_strategy": "lines",
|
|
374
|
-
"horizontal_strategy": "lines",
|
|
375
|
-
"snap_tolerance": 3,
|
|
376
|
-
"intersection_tolerance": 15
|
|
377
|
-
}
|
|
378
|
-
tables = page.extract_tables(table_settings)
|
|
379
|
-
|
|
380
|
-
# Visual debugging for table extraction
|
|
381
|
-
img = page.to_image(resolution=150)
|
|
382
|
-
img.save("debug_layout.png")
|
|
383
|
-
```
|
|
390
|
+
# HTML output
|
|
391
|
+
pdftotext -htmlmeta input.pdf output.html
|
|
384
392
|
|
|
385
|
-
|
|
393
|
+
# Encoding
|
|
394
|
+
pdftotext -enc UTF-8 input.pdf output.txt
|
|
386
395
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
from reportlab.lib.styles import getSampleStyleSheet
|
|
391
|
-
from reportlab.lib import colors
|
|
396
|
+
# Extract with bounding box coordinates (XML)
|
|
397
|
+
pdftotext -bbox-layout input.pdf output.xml
|
|
398
|
+
```
|
|
392
399
|
|
|
393
|
-
|
|
394
|
-
data = [
|
|
395
|
-
['Product', 'Q1', 'Q2', 'Q3', 'Q4'],
|
|
396
|
-
['Widgets', '120', '135', '142', '158'],
|
|
397
|
-
['Gadgets', '85', '92', '98', '105']
|
|
398
|
-
]
|
|
400
|
+
### pdfimages (poppler)
|
|
399
401
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
402
|
+
```bash
|
|
403
|
+
# Extract as PNG
|
|
404
|
+
pdfimages -png input.pdf outdir/prefix
|
|
403
405
|
|
|
404
|
-
#
|
|
405
|
-
|
|
406
|
-
title = Paragraph("Quarterly Sales Report", styles['Title'])
|
|
407
|
-
elements.append(title)
|
|
406
|
+
# Extract in original format
|
|
407
|
+
pdfimages -all input.pdf outdir/prefix
|
|
408
408
|
|
|
409
|
-
#
|
|
410
|
-
|
|
411
|
-
table.setStyle(TableStyle([
|
|
412
|
-
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
|
413
|
-
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
|
414
|
-
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
415
|
-
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
416
|
-
('FONTSIZE', (0, 0), (-1, 0), 14),
|
|
417
|
-
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
|
418
|
-
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
|
419
|
-
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
|
420
|
-
]))
|
|
421
|
-
elements.append(table)
|
|
409
|
+
# List images (no extraction)
|
|
410
|
+
pdfimages -list input.pdf
|
|
422
411
|
|
|
423
|
-
|
|
412
|
+
# Specific page range
|
|
413
|
+
pdfimages -f 1 -l 5 -png input.pdf outdir/prefix
|
|
424
414
|
```
|
|
425
415
|
|
|
426
|
-
|
|
416
|
+
### tesseract (OCR)
|
|
427
417
|
|
|
428
|
-
|
|
418
|
+
```bash
|
|
419
|
+
# Basic OCR
|
|
420
|
+
tesseract image.png output_base # produces output_base.txt
|
|
421
|
+
|
|
422
|
+
# Specify language
|
|
423
|
+
tesseract image.png output_base -l eng+fra
|
|
424
|
+
|
|
425
|
+
# Output formats
|
|
426
|
+
tesseract image.png output_base pdf # searchable PDF
|
|
427
|
+
tesseract image.png output_base hocr # HTML with coordinates
|
|
428
|
+
tesseract image.png output_base tsv # tab-separated values
|
|
429
|
+
|
|
430
|
+
# Page segmentation modes (--psm)
|
|
431
|
+
# 3 = fully automatic (default)
|
|
432
|
+
# 6 = assume uniform block of text
|
|
433
|
+
# 7 = single text line
|
|
434
|
+
# 8 = single word
|
|
435
|
+
tesseract image.png output_base --psm 6
|
|
436
|
+
|
|
437
|
+
# List available languages
|
|
438
|
+
tesseract --list-langs
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### pdftoppm (poppler -- image conversion)
|
|
429
442
|
|
|
430
|
-
#### Method 1: Using pdfimages (fastest)
|
|
431
443
|
```bash
|
|
432
|
-
#
|
|
433
|
-
|
|
444
|
+
# Convert to PNG at 300 DPI
|
|
445
|
+
pdftoppm -png -r 300 input.pdf output_prefix
|
|
446
|
+
|
|
447
|
+
# Convert specific pages
|
|
448
|
+
pdftoppm -png -r 300 -f 1 -l 3 input.pdf output_prefix
|
|
449
|
+
|
|
450
|
+
# Convert to JPEG with quality setting
|
|
451
|
+
pdftoppm -jpeg -jpegopt quality=85 -r 200 input.pdf output_prefix
|
|
434
452
|
```
|
|
435
453
|
|
|
436
|
-
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Batch Processing
|
|
457
|
+
|
|
458
|
+
### Process many PDFs in parallel
|
|
459
|
+
|
|
437
460
|
```python
|
|
438
|
-
|
|
439
|
-
from
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
# Simple figure detection (non-white regions)
|
|
454
|
-
mask = np.any(img_array != [255, 255, 255], axis=2)
|
|
455
|
-
|
|
456
|
-
# Find contours and extract bounding boxes
|
|
457
|
-
# (This is simplified - real implementation would need more sophisticated detection)
|
|
458
|
-
|
|
459
|
-
# Save detected figures
|
|
460
|
-
# ... implementation depends on specific needs
|
|
461
|
+
from pathlib import Path
|
|
462
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
463
|
+
from pypdf import PdfReader
|
|
464
|
+
|
|
465
|
+
def extract_text(pdf_path: str) -> tuple[str, str]:
|
|
466
|
+
reader = PdfReader(pdf_path)
|
|
467
|
+
text = "\n".join(page.extract_text() or "" for page in reader.pages)
|
|
468
|
+
return pdf_path, text
|
|
469
|
+
|
|
470
|
+
pdf_files = list(Path("inbox").glob("*.pdf"))
|
|
471
|
+
|
|
472
|
+
with ProcessPoolExecutor(max_workers=4) as executor:
|
|
473
|
+
results = executor.map(extract_text, [str(p) for p in pdf_files])
|
|
474
|
+
for path, text in results:
|
|
475
|
+
print(f"{path}: {len(text)} chars")
|
|
461
476
|
```
|
|
462
477
|
|
|
463
|
-
### Batch
|
|
478
|
+
### Batch processing with error handling
|
|
479
|
+
|
|
464
480
|
```python
|
|
465
|
-
import
|
|
466
|
-
import glob
|
|
481
|
+
from pathlib import Path
|
|
467
482
|
from pypdf import PdfReader, PdfWriter
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
try:
|
|
480
|
-
reader = PdfReader(pdf_file)
|
|
481
|
-
for page in reader.pages:
|
|
482
|
-
writer.add_page(page)
|
|
483
|
-
logger.info(f"Processed: {pdf_file}")
|
|
484
|
-
except Exception as e:
|
|
485
|
-
logger.error(f"Failed to process {pdf_file}: {e}")
|
|
486
|
-
continue
|
|
487
|
-
|
|
488
|
-
with open("batch_merged.pdf", "wb") as output:
|
|
489
|
-
writer.write(output)
|
|
490
|
-
|
|
491
|
-
elif operation == 'extract_text':
|
|
492
|
-
for pdf_file in pdf_files:
|
|
493
|
-
try:
|
|
494
|
-
reader = PdfReader(pdf_file)
|
|
495
|
-
text = ""
|
|
496
|
-
for page in reader.pages:
|
|
497
|
-
text += page.extract_text()
|
|
498
|
-
|
|
499
|
-
output_file = pdf_file.replace('.pdf', '.txt')
|
|
500
|
-
with open(output_file, 'w', encoding='utf-8') as f:
|
|
501
|
-
f.write(text)
|
|
502
|
-
logger.info(f"Extracted text from: {pdf_file}")
|
|
503
|
-
|
|
504
|
-
except Exception as e:
|
|
505
|
-
logger.error(f"Failed to extract text from {pdf_file}: {e}")
|
|
506
|
-
continue
|
|
483
|
+
|
|
484
|
+
def process_pdfs(input_dir, output_dir, operation):
|
|
485
|
+
"""Apply an operation to all PDFs in a directory."""
|
|
486
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
487
|
+
for pdf_path in sorted(Path(input_dir).glob("*.pdf")):
|
|
488
|
+
try:
|
|
489
|
+
reader = PdfReader(str(pdf_path))
|
|
490
|
+
operation(reader, pdf_path, output_dir)
|
|
491
|
+
print(f"OK: {pdf_path.name}")
|
|
492
|
+
except Exception as e:
|
|
493
|
+
print(f"FAIL: {pdf_path.name}: {e}")
|
|
507
494
|
```
|
|
508
495
|
|
|
509
|
-
|
|
510
|
-
```python
|
|
511
|
-
from pypdf import PdfWriter, PdfReader
|
|
496
|
+
---
|
|
512
497
|
|
|
513
|
-
|
|
514
|
-
writer = PdfWriter()
|
|
498
|
+
## Performance Tips
|
|
515
499
|
|
|
516
|
-
|
|
517
|
-
page = reader.pages[0]
|
|
518
|
-
page.mediabox.left = 50
|
|
519
|
-
page.mediabox.bottom = 50
|
|
520
|
-
page.mediabox.right = 550
|
|
521
|
-
page.mediabox.top = 750
|
|
500
|
+
### Choose the right rendering library
|
|
522
501
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
502
|
+
| Task | Use | Why |
|
|
503
|
+
|---|---|---|
|
|
504
|
+
| Render to image | pypdfium2 | 5-10x faster than pdf2image, no poppler dependency |
|
|
505
|
+
| Render many pages | pypdfium2 | Lower memory, C-based |
|
|
506
|
+
| Need exact poppler compat | pdf2image | Wraps poppler's pdftoppm |
|
|
527
507
|
|
|
528
|
-
|
|
508
|
+
### Memory efficiency for large PDFs
|
|
529
509
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
- Use `qpdf --split-pages` for splitting large files
|
|
533
|
-
- Process pages individually with pypdfium2
|
|
510
|
+
```python
|
|
511
|
+
from pypdf import PdfReader, PdfWriter
|
|
534
512
|
|
|
535
|
-
|
|
536
|
-
- `pdftotext -bbox-layout` is fastest for plain text extraction
|
|
537
|
-
- Use pdfplumber for structured data and tables
|
|
538
|
-
- Avoid `pypdf.extract_text()` for very large documents
|
|
513
|
+
reader = PdfReader("large.pdf")
|
|
539
514
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
515
|
+
# Process pages one at a time instead of loading all
|
|
516
|
+
for i in range(len(reader.pages)):
|
|
517
|
+
writer = PdfWriter()
|
|
518
|
+
writer.add_page(reader.pages[i])
|
|
543
519
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
- Pre-validate form fields before processing
|
|
520
|
+
text = reader.pages[i].extract_text()
|
|
521
|
+
# process text...
|
|
547
522
|
|
|
548
|
-
|
|
549
|
-
```python
|
|
550
|
-
# Process PDFs in chunks
|
|
551
|
-
def process_large_pdf(pdf_path, chunk_size=10):
|
|
552
|
-
reader = PdfReader(pdf_path)
|
|
553
|
-
total_pages = len(reader.pages)
|
|
554
|
-
|
|
555
|
-
for start_idx in range(0, total_pages, chunk_size):
|
|
556
|
-
end_idx = min(start_idx + chunk_size, total_pages)
|
|
557
|
-
writer = PdfWriter()
|
|
558
|
-
|
|
559
|
-
for i in range(start_idx, end_idx):
|
|
560
|
-
writer.add_page(reader.pages[i])
|
|
561
|
-
|
|
562
|
-
# Process chunk
|
|
563
|
-
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
|
|
564
|
-
writer.write(output)
|
|
523
|
+
writer.close() # free memory
|
|
565
524
|
```
|
|
566
525
|
|
|
567
|
-
|
|
526
|
+
### Compress PDF with Ghostscript
|
|
568
527
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
from pypdf import PdfReader
|
|
573
|
-
|
|
574
|
-
try:
|
|
575
|
-
reader = PdfReader("encrypted.pdf")
|
|
576
|
-
if reader.is_encrypted:
|
|
577
|
-
reader.decrypt("password")
|
|
578
|
-
except Exception as e:
|
|
579
|
-
print(f"Failed to decrypt: {e}")
|
|
528
|
+
```bash
|
|
529
|
+
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/ebook \
|
|
530
|
+
-dNOPAUSE -dBATCH -sOutputFile=compressed.pdf input.pdf
|
|
580
531
|
```
|
|
581
532
|
|
|
582
|
-
|
|
533
|
+
Settings: `/screen` (72dpi), `/ebook` (150dpi), `/printer` (300dpi), `/prepress` (300dpi, color-preserving).
|
|
534
|
+
|
|
535
|
+
### Repair damaged PDFs
|
|
536
|
+
|
|
583
537
|
```bash
|
|
584
|
-
|
|
585
|
-
qpdf
|
|
586
|
-
|
|
538
|
+
qpdf --replace-input damaged.pdf # In-place repair
|
|
539
|
+
qpdf damaged.pdf repaired.pdf # qpdf fixes many structural issues automatically
|
|
540
|
+
gs -o repaired.pdf -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress damaged.pdf # Ghostscript rewrite
|
|
587
541
|
```
|
|
588
542
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
import pytesseract
|
|
593
|
-
from pdf2image import convert_from_path
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Coordinate Systems
|
|
594
546
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
547
|
+
PDF coordinates can be confusing. Key facts:
|
|
548
|
+
|
|
549
|
+
- **PDF native**: origin at bottom-left, Y increases upward. Units are points (1 pt = 1/72 inch).
|
|
550
|
+
- **pdfplumber**: origin at top-left, Y increases downward (like screen coordinates). Uses the same point scale.
|
|
551
|
+
- **Rendered images**: origin at top-left, Y increases downward. Units are pixels. Conversion: `pdf_points = pixels * 72 / dpi`.
|
|
552
|
+
|
|
553
|
+
### Converting between systems
|
|
554
|
+
|
|
555
|
+
```python
|
|
556
|
+
def image_coords_to_pdf(pixel_x, pixel_y, page_height_pts, dpi):
|
|
557
|
+
"""Convert rendered image pixel coordinates to PDF coordinates."""
|
|
558
|
+
pdf_x = pixel_x * 72.0 / dpi
|
|
559
|
+
pdf_y = page_height_pts - (pixel_y * 72.0 / dpi)
|
|
560
|
+
return pdf_x, pdf_y
|
|
561
|
+
|
|
562
|
+
def pdf_coords_to_image(pdf_x, pdf_y, page_height_pts, dpi):
|
|
563
|
+
"""Convert PDF coordinates to rendered image pixel coordinates."""
|
|
564
|
+
pixel_x = pdf_x * dpi / 72.0
|
|
565
|
+
pixel_y = (page_height_pts - pdf_y) * dpi / 72.0
|
|
566
|
+
return pixel_x, pixel_y
|
|
601
567
|
```
|
|
602
568
|
|
|
603
|
-
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Library License Information
|
|
604
572
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
573
|
+
| Library | License |
|
|
574
|
+
|---|---|
|
|
575
|
+
| pypdf | BSD |
|
|
576
|
+
| pdfplumber | MIT |
|
|
577
|
+
| pypdfium2 | Apache/BSD |
|
|
578
|
+
| reportlab | BSD |
|
|
579
|
+
| pytesseract | Apache-2.0 |
|
|
580
|
+
| Pillow | MIT-like (HPND) |
|
|
581
|
+
| poppler-utils | GPL-2 |
|
|
582
|
+
| qpdf | Apache |
|