exis-pdfeditor 3.4.1__py3-none-win_amd64.whl
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.
- exis_pdfeditor-3.4.1.data/purelib/exis_pdfeditor/__init__.py +774 -0
- exis_pdfeditor-3.4.1.data/purelib/exis_pdfeditor/_cli.py +203 -0
- exis_pdfeditor-3.4.1.data/purelib/exis_pdfeditor/bin/exis-pdf.exe +0 -0
- exis_pdfeditor-3.4.1.dist-info/METADATA +563 -0
- exis_pdfeditor-3.4.1.dist-info/RECORD +7 -0
- exis_pdfeditor-3.4.1.dist-info/WHEEL +5 -0
- exis_pdfeditor-3.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exis.PdfEditor — Python wrapper for the exis-pdf CLI.
|
|
3
|
+
|
|
4
|
+
Comprehensive PDF toolkit: find/replace, merge, split, form filling, redaction,
|
|
5
|
+
image editing, watermark, page editing, encryption, optimization, digital
|
|
6
|
+
signatures, PDF/A compliance, and more.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import exis_pdfeditor
|
|
11
|
+
|
|
12
|
+
# Point to the native CLI binary (built with Native AOT)
|
|
13
|
+
exis_pdfeditor.set_cli_path("/path/to/exis-pdf")
|
|
14
|
+
|
|
15
|
+
# Optional: set license key (or use EXIS_PDF_LICENSE_KEY env var)
|
|
16
|
+
exis_pdfeditor.initialize("XXXX-XXXX-XXXX-XXXX")
|
|
17
|
+
|
|
18
|
+
# Inspect a PDF (free, no license required)
|
|
19
|
+
info = exis_pdfeditor.inspect("document.pdf")
|
|
20
|
+
print(info.pageCount, info.title)
|
|
21
|
+
|
|
22
|
+
# Find and replace
|
|
23
|
+
result = exis_pdfeditor.find_replace(
|
|
24
|
+
"input.pdf", "output.pdf", "old text", "new text",
|
|
25
|
+
case_sensitive=False,
|
|
26
|
+
)
|
|
27
|
+
print(result.totalReplacements)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from typing import Any, Sequence
|
|
34
|
+
|
|
35
|
+
from ._cli import (
|
|
36
|
+
PdfEditorError,
|
|
37
|
+
LicenseError,
|
|
38
|
+
EvaluationLimitError,
|
|
39
|
+
run as _run,
|
|
40
|
+
set_cli_path,
|
|
41
|
+
set_license_key,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# Setup
|
|
46
|
+
"set_cli_path",
|
|
47
|
+
"set_license_key",
|
|
48
|
+
"initialize",
|
|
49
|
+
# Exceptions
|
|
50
|
+
"PdfEditorError",
|
|
51
|
+
"LicenseError",
|
|
52
|
+
"EvaluationLimitError",
|
|
53
|
+
# Inspection / text
|
|
54
|
+
"inspect",
|
|
55
|
+
"dump_structure",
|
|
56
|
+
"extract_text",
|
|
57
|
+
"extract_text_structured",
|
|
58
|
+
# Find & replace
|
|
59
|
+
"find_replace",
|
|
60
|
+
# Merge / split
|
|
61
|
+
"merge",
|
|
62
|
+
"split",
|
|
63
|
+
"extract_pages",
|
|
64
|
+
# Forms
|
|
65
|
+
"list_fields",
|
|
66
|
+
"fill_form",
|
|
67
|
+
# Redaction
|
|
68
|
+
"redact",
|
|
69
|
+
# Watermark / stamp
|
|
70
|
+
"watermark",
|
|
71
|
+
"stamp",
|
|
72
|
+
# Optimization
|
|
73
|
+
"optimize",
|
|
74
|
+
# Security
|
|
75
|
+
"encrypt",
|
|
76
|
+
"decrypt",
|
|
77
|
+
# Page editing
|
|
78
|
+
"rotate",
|
|
79
|
+
"crop",
|
|
80
|
+
"reorder",
|
|
81
|
+
"delete_pages",
|
|
82
|
+
# Images
|
|
83
|
+
"find_images",
|
|
84
|
+
"replace_image",
|
|
85
|
+
# Signatures
|
|
86
|
+
"sign",
|
|
87
|
+
"verify",
|
|
88
|
+
# PDF/A
|
|
89
|
+
"pdfa_validate",
|
|
90
|
+
"pdfa_convert",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
__version__ = "3.4.1"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
97
|
+
# Setup
|
|
98
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
def initialize(license_key: str | None = None) -> None:
|
|
101
|
+
"""Store the license key for all subsequent operations.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
license_key : str, optional
|
|
106
|
+
License key in ``XXXX-XXXX-XXXX-XXXX`` format.
|
|
107
|
+
If None, the library operates in trial/evaluation mode.
|
|
108
|
+
Can also be set via the ``EXIS_PDF_LICENSE_KEY`` environment variable.
|
|
109
|
+
"""
|
|
110
|
+
set_license_key(license_key)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
114
|
+
# Inspection / text extraction
|
|
115
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
def inspect(input_path: str) -> Any:
|
|
118
|
+
"""Inspect PDF metadata, page info, fonts, and structure.
|
|
119
|
+
|
|
120
|
+
No license required.
|
|
121
|
+
|
|
122
|
+
Returns a namespace with attributes: ``version``, ``pageCount``,
|
|
123
|
+
``title``, ``author``, ``producer``, ``creator``, ``creationDate``,
|
|
124
|
+
``modificationDate``, ``isEncrypted``, ``hasFormFields``,
|
|
125
|
+
``formFieldCount``, ``hasDigitalSignatures``, ``fontsUsed``, ``pages``.
|
|
126
|
+
"""
|
|
127
|
+
return _run("inspect", input=input_path)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def dump_structure(input_path: str) -> Any:
|
|
131
|
+
"""Low-level structural dump for diagnostics. No license required."""
|
|
132
|
+
return _run("dump-structure", input=input_path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_text(
|
|
136
|
+
input_path: str,
|
|
137
|
+
*,
|
|
138
|
+
pages: Sequence[int] | None = None,
|
|
139
|
+
) -> Any:
|
|
140
|
+
"""Extract plain text from a PDF.
|
|
141
|
+
|
|
142
|
+
Returns a namespace with ``pages`` (list of {pageNumber, text})
|
|
143
|
+
and ``fullText``.
|
|
144
|
+
"""
|
|
145
|
+
return _run(
|
|
146
|
+
"extract-text",
|
|
147
|
+
input=input_path,
|
|
148
|
+
pages=_int_csv(pages),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_text_structured(
|
|
153
|
+
input_path: str,
|
|
154
|
+
*,
|
|
155
|
+
pages: Sequence[int] | None = None,
|
|
156
|
+
) -> Any:
|
|
157
|
+
"""Extract structured text with position and font metadata.
|
|
158
|
+
|
|
159
|
+
Each text block includes ``x``, ``y``, ``fontName``, ``fontSize``.
|
|
160
|
+
"""
|
|
161
|
+
return _run(
|
|
162
|
+
"extract-text",
|
|
163
|
+
input=input_path,
|
|
164
|
+
pages=_int_csv(pages),
|
|
165
|
+
structured=True,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
170
|
+
# Find & replace
|
|
171
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
def find_replace(
|
|
174
|
+
input_path: str,
|
|
175
|
+
output_path: str,
|
|
176
|
+
search: str | None = None,
|
|
177
|
+
replace: str | None = None,
|
|
178
|
+
*,
|
|
179
|
+
pairs: list[dict[str, Any]] | None = None,
|
|
180
|
+
case_sensitive: bool = True,
|
|
181
|
+
whole_word: bool = False,
|
|
182
|
+
use_regex: bool = False,
|
|
183
|
+
page_range: Sequence[int] | None = None,
|
|
184
|
+
text_fitting: str = "none",
|
|
185
|
+
min_horizontal_scale: int = 70,
|
|
186
|
+
max_font_size_reduction: float = 1.5,
|
|
187
|
+
preserve_form_fields: bool = True,
|
|
188
|
+
use_incremental_update: bool = True,
|
|
189
|
+
replacement_text_color: dict | None = None,
|
|
190
|
+
replacement_highlight_color: dict | None = None,
|
|
191
|
+
replacement_bold: bool = False,
|
|
192
|
+
replacement_underline: bool = False,
|
|
193
|
+
replacement_strikethrough: bool = False,
|
|
194
|
+
) -> Any:
|
|
195
|
+
"""Find and replace text in a PDF.
|
|
196
|
+
|
|
197
|
+
Either provide ``search``/``replace`` for a single pair, or ``pairs``
|
|
198
|
+
as a list of ``{"search": "...", "replace": "...", "isRegex": false}``.
|
|
199
|
+
|
|
200
|
+
Returns a namespace with ``totalReplacements``, ``pagesModified``,
|
|
201
|
+
``pagesSearched``, ``details``, ``warnings``.
|
|
202
|
+
"""
|
|
203
|
+
fitting_map = {
|
|
204
|
+
"none": 0, "preserve_width": 1, "fit_to_page": 2, "adaptive": 3,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
options: dict[str, Any] = {}
|
|
208
|
+
if not case_sensitive:
|
|
209
|
+
options["caseSensitive"] = False
|
|
210
|
+
if whole_word:
|
|
211
|
+
options["wholeWordOnly"] = True
|
|
212
|
+
if use_regex:
|
|
213
|
+
options["useRegex"] = True
|
|
214
|
+
if page_range:
|
|
215
|
+
options["pageRange"] = list(page_range)
|
|
216
|
+
if text_fitting != "none":
|
|
217
|
+
options["textFitting"] = fitting_map.get(text_fitting, 0)
|
|
218
|
+
if min_horizontal_scale != 70:
|
|
219
|
+
options["minHorizontalScale"] = min_horizontal_scale
|
|
220
|
+
if max_font_size_reduction != 1.5:
|
|
221
|
+
options["maxFontSizeReduction"] = max_font_size_reduction
|
|
222
|
+
if not preserve_form_fields:
|
|
223
|
+
options["preserveFormFields"] = False
|
|
224
|
+
if not use_incremental_update:
|
|
225
|
+
options["useIncrementalUpdate"] = False
|
|
226
|
+
if replacement_text_color:
|
|
227
|
+
options["replacementTextColor"] = replacement_text_color
|
|
228
|
+
if replacement_highlight_color:
|
|
229
|
+
options["replacementHighlightColor"] = replacement_highlight_color
|
|
230
|
+
if replacement_bold:
|
|
231
|
+
options["replacementBold"] = True
|
|
232
|
+
if replacement_underline:
|
|
233
|
+
options["replacementUnderline"] = True
|
|
234
|
+
if replacement_strikethrough:
|
|
235
|
+
options["replacementStrikethrough"] = True
|
|
236
|
+
|
|
237
|
+
kwargs: dict[str, Any] = {
|
|
238
|
+
"input": input_path,
|
|
239
|
+
"output": output_path,
|
|
240
|
+
}
|
|
241
|
+
if options:
|
|
242
|
+
kwargs["options"] = json.dumps(options)
|
|
243
|
+
|
|
244
|
+
if pairs is not None:
|
|
245
|
+
kwargs["pairs"] = json.dumps(pairs)
|
|
246
|
+
else:
|
|
247
|
+
if search is None or replace is None:
|
|
248
|
+
raise ValueError("Provide either search/replace or pairs.")
|
|
249
|
+
kwargs["search"] = search
|
|
250
|
+
kwargs["replace"] = replace
|
|
251
|
+
|
|
252
|
+
return _run("find-replace", **kwargs)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
256
|
+
# Merge / split
|
|
257
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
258
|
+
|
|
259
|
+
def merge(
|
|
260
|
+
input_paths: Sequence[str],
|
|
261
|
+
output_path: str,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Merge multiple PDF files into one."""
|
|
264
|
+
_run("merge", inputs=list(input_paths), output=output_path)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def split(input_path: str, output_dir: str) -> Any:
|
|
268
|
+
"""Split a PDF into individual single-page PDFs.
|
|
269
|
+
|
|
270
|
+
Returns a namespace with ``pageCount`` and ``files``.
|
|
271
|
+
"""
|
|
272
|
+
return _run("split", input=input_path, output_dir=output_dir)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def extract_pages(
|
|
276
|
+
input_path: str,
|
|
277
|
+
output_path: str,
|
|
278
|
+
pages: Sequence[int],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Extract specific pages into a new PDF."""
|
|
281
|
+
_run(
|
|
282
|
+
"extract-pages",
|
|
283
|
+
input=input_path,
|
|
284
|
+
output=output_path,
|
|
285
|
+
pages=_int_csv(pages),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
290
|
+
# Forms
|
|
291
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
292
|
+
|
|
293
|
+
def list_fields(input_path: str) -> Any:
|
|
294
|
+
"""List all form fields in a PDF.
|
|
295
|
+
|
|
296
|
+
Returns a list of namespaces with ``name``, ``displayName``, ``type``,
|
|
297
|
+
``value``, ``options``, ``isReadOnly``.
|
|
298
|
+
"""
|
|
299
|
+
return _run("list-fields", input=input_path)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fill_form(
|
|
303
|
+
input_path: str,
|
|
304
|
+
output_path: str,
|
|
305
|
+
fields: dict[str, str],
|
|
306
|
+
*,
|
|
307
|
+
flatten: bool = False,
|
|
308
|
+
) -> Any:
|
|
309
|
+
"""Fill form fields and optionally flatten.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
fields : dict
|
|
314
|
+
Mapping of field name to value.
|
|
315
|
+
flatten : bool
|
|
316
|
+
If True, flatten the form after filling (makes fields non-editable).
|
|
317
|
+
|
|
318
|
+
Returns a namespace with ``fieldsFilled``, ``fieldsNotFound``,
|
|
319
|
+
``filledFieldNames``, ``notFoundFieldNames``, ``warnings``.
|
|
320
|
+
"""
|
|
321
|
+
kwargs: dict[str, Any] = {
|
|
322
|
+
"input": input_path,
|
|
323
|
+
"output": output_path,
|
|
324
|
+
"fields": json.dumps(fields),
|
|
325
|
+
}
|
|
326
|
+
if flatten:
|
|
327
|
+
kwargs["flatten"] = True
|
|
328
|
+
return _run("fill-form", **kwargs)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
332
|
+
# Redaction
|
|
333
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
334
|
+
|
|
335
|
+
def redact(
|
|
336
|
+
input_path: str,
|
|
337
|
+
output_path: str,
|
|
338
|
+
redactions: list[dict[str, Any]],
|
|
339
|
+
) -> Any:
|
|
340
|
+
"""Redact text and/or areas in a PDF.
|
|
341
|
+
|
|
342
|
+
Each redaction dict can have: ``text``, ``isRegex``, ``caseSensitive``,
|
|
343
|
+
``replaceWith``, ``pageNumber``, ``area`` (dict with x, y, width, height).
|
|
344
|
+
|
|
345
|
+
Returns a namespace with ``redactionsApplied``, ``pagesModified``,
|
|
346
|
+
``warnings``.
|
|
347
|
+
"""
|
|
348
|
+
return _run(
|
|
349
|
+
"redact",
|
|
350
|
+
input=input_path,
|
|
351
|
+
output=output_path,
|
|
352
|
+
redactions=json.dumps(redactions),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
357
|
+
# Watermark / stamp
|
|
358
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
359
|
+
|
|
360
|
+
def watermark(
|
|
361
|
+
input_path: str,
|
|
362
|
+
output_path: str,
|
|
363
|
+
text: str,
|
|
364
|
+
*,
|
|
365
|
+
position: str | None = None,
|
|
366
|
+
font_size: float | None = None,
|
|
367
|
+
text_color: dict | None = None,
|
|
368
|
+
opacity: float | None = None,
|
|
369
|
+
page_range: Sequence[int] | None = None,
|
|
370
|
+
) -> Any:
|
|
371
|
+
"""Add a text watermark to a PDF.
|
|
372
|
+
|
|
373
|
+
Parameters
|
|
374
|
+
----------
|
|
375
|
+
position : str, optional
|
|
376
|
+
One of "top", "bottom", "center", "across" (default: "across").
|
|
377
|
+
text_color : dict, optional
|
|
378
|
+
RGB color as ``{"r": 0.75, "g": 0.75, "b": 0.75}``.
|
|
379
|
+
|
|
380
|
+
Returns a namespace with ``pagesWatermarked``, ``totalPages``.
|
|
381
|
+
"""
|
|
382
|
+
options: dict[str, Any] = {}
|
|
383
|
+
if position:
|
|
384
|
+
pos_map = {"top": 0, "bottom": 1, "center": 2, "across": 3}
|
|
385
|
+
options["position"] = pos_map.get(position.lower(), 3)
|
|
386
|
+
if font_size is not None:
|
|
387
|
+
options["fontSize"] = font_size
|
|
388
|
+
if text_color:
|
|
389
|
+
options["textColor"] = text_color
|
|
390
|
+
if opacity is not None:
|
|
391
|
+
options["opacity"] = opacity
|
|
392
|
+
if page_range:
|
|
393
|
+
options["pageRange"] = list(page_range)
|
|
394
|
+
|
|
395
|
+
kwargs: dict[str, Any] = {
|
|
396
|
+
"input": input_path,
|
|
397
|
+
"output": output_path,
|
|
398
|
+
"text": text,
|
|
399
|
+
}
|
|
400
|
+
if options:
|
|
401
|
+
kwargs["options"] = json.dumps(options)
|
|
402
|
+
|
|
403
|
+
return _run("watermark", **kwargs)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def stamp(
|
|
407
|
+
input_path: str,
|
|
408
|
+
output_path: str,
|
|
409
|
+
stamp_path: str,
|
|
410
|
+
*,
|
|
411
|
+
mode: str = "overlay",
|
|
412
|
+
page_range: Sequence[int] | None = None,
|
|
413
|
+
stamp_page_number: int = 1,
|
|
414
|
+
opacity: float = 1.0,
|
|
415
|
+
) -> Any:
|
|
416
|
+
"""Overlay or underlay a PDF stamp onto pages.
|
|
417
|
+
|
|
418
|
+
Parameters
|
|
419
|
+
----------
|
|
420
|
+
mode : str
|
|
421
|
+
"overlay" (on top) or "underlay" (behind).
|
|
422
|
+
|
|
423
|
+
Returns a namespace with ``pagesStamped``.
|
|
424
|
+
"""
|
|
425
|
+
options: dict[str, Any] = {}
|
|
426
|
+
if page_range:
|
|
427
|
+
options["pageRange"] = list(page_range)
|
|
428
|
+
if stamp_page_number != 1:
|
|
429
|
+
options["stampPageNumber"] = stamp_page_number
|
|
430
|
+
if opacity != 1.0:
|
|
431
|
+
options["opacity"] = opacity
|
|
432
|
+
|
|
433
|
+
kwargs: dict[str, Any] = {
|
|
434
|
+
"input": input_path,
|
|
435
|
+
"output": output_path,
|
|
436
|
+
"stamp": stamp_path,
|
|
437
|
+
"mode": mode,
|
|
438
|
+
}
|
|
439
|
+
if options:
|
|
440
|
+
kwargs["options"] = json.dumps(options)
|
|
441
|
+
|
|
442
|
+
return _run("stamp", **kwargs)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
446
|
+
# Optimization
|
|
447
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
448
|
+
|
|
449
|
+
def optimize(
|
|
450
|
+
input_path: str,
|
|
451
|
+
output_path: str,
|
|
452
|
+
*,
|
|
453
|
+
compress_streams: bool = True,
|
|
454
|
+
remove_duplicate_objects: bool = True,
|
|
455
|
+
remove_metadata: bool = False,
|
|
456
|
+
downsample_images: bool = False,
|
|
457
|
+
max_image_dpi: int = 150,
|
|
458
|
+
) -> Any:
|
|
459
|
+
"""Optimize a PDF by compressing streams and removing duplicates.
|
|
460
|
+
|
|
461
|
+
Returns a namespace with ``originalSize``, ``optimizedSize``,
|
|
462
|
+
``bytesSaved``, ``reductionPercent``, ``streamsCompressed``,
|
|
463
|
+
``duplicatesRemoved``, ``imagesDownsampled``.
|
|
464
|
+
"""
|
|
465
|
+
options: dict[str, Any] = {}
|
|
466
|
+
if not compress_streams:
|
|
467
|
+
options["compressStreams"] = False
|
|
468
|
+
if not remove_duplicate_objects:
|
|
469
|
+
options["removeDuplicateObjects"] = False
|
|
470
|
+
if remove_metadata:
|
|
471
|
+
options["removeMetadata"] = True
|
|
472
|
+
if downsample_images:
|
|
473
|
+
options["downsampleImages"] = True
|
|
474
|
+
if max_image_dpi != 150:
|
|
475
|
+
options["maxImageDpi"] = max_image_dpi
|
|
476
|
+
|
|
477
|
+
kwargs: dict[str, Any] = {
|
|
478
|
+
"input": input_path,
|
|
479
|
+
"output": output_path,
|
|
480
|
+
}
|
|
481
|
+
if options:
|
|
482
|
+
kwargs["options"] = json.dumps(options)
|
|
483
|
+
|
|
484
|
+
return _run("optimize", **kwargs)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
488
|
+
# Security
|
|
489
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
490
|
+
|
|
491
|
+
def encrypt(
|
|
492
|
+
input_path: str,
|
|
493
|
+
output_path: str,
|
|
494
|
+
*,
|
|
495
|
+
user_password: str = "",
|
|
496
|
+
owner_password: str | None = None,
|
|
497
|
+
permissions: Sequence[str] | None = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Encrypt a PDF with AES-256 password protection.
|
|
500
|
+
|
|
501
|
+
Parameters
|
|
502
|
+
----------
|
|
503
|
+
permissions : list of str, optional
|
|
504
|
+
Subset of: "Print", "ModifyContents", "CopyText",
|
|
505
|
+
"AddAnnotations", "FillForms", "PrintHighQuality", "All".
|
|
506
|
+
"""
|
|
507
|
+
kwargs: dict[str, Any] = {
|
|
508
|
+
"input": input_path,
|
|
509
|
+
"output": output_path,
|
|
510
|
+
"user_password": user_password,
|
|
511
|
+
"owner_password": owner_password or user_password,
|
|
512
|
+
}
|
|
513
|
+
if permissions:
|
|
514
|
+
kwargs["permissions"] = ",".join(permissions)
|
|
515
|
+
_run("encrypt", **kwargs)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def decrypt(
|
|
519
|
+
input_path: str,
|
|
520
|
+
output_path: str,
|
|
521
|
+
password: str,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""Decrypt a password-protected PDF."""
|
|
524
|
+
_run("decrypt", input=input_path, output=output_path, password=password)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
528
|
+
# Page editing
|
|
529
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
530
|
+
|
|
531
|
+
def rotate(
|
|
532
|
+
input_path: str,
|
|
533
|
+
output_path: str,
|
|
534
|
+
angle: int,
|
|
535
|
+
*,
|
|
536
|
+
pages: Sequence[int] | None = None,
|
|
537
|
+
) -> Any:
|
|
538
|
+
"""Rotate pages by the given angle (must be a multiple of 90).
|
|
539
|
+
|
|
540
|
+
Returns a namespace with ``pagesModified``.
|
|
541
|
+
"""
|
|
542
|
+
return _run(
|
|
543
|
+
"rotate",
|
|
544
|
+
input=input_path,
|
|
545
|
+
output=output_path,
|
|
546
|
+
angle=str(angle),
|
|
547
|
+
pages=_int_csv(pages),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def crop(
|
|
552
|
+
input_path: str,
|
|
553
|
+
output_path: str,
|
|
554
|
+
rect: dict[str, float],
|
|
555
|
+
*,
|
|
556
|
+
pages: Sequence[int] | None = None,
|
|
557
|
+
) -> Any:
|
|
558
|
+
"""Set the CropBox on pages.
|
|
559
|
+
|
|
560
|
+
Parameters
|
|
561
|
+
----------
|
|
562
|
+
rect : dict
|
|
563
|
+
``{"x": float, "y": float, "width": float, "height": float}``
|
|
564
|
+
in PDF points (72pt = 1 inch).
|
|
565
|
+
|
|
566
|
+
Returns a namespace with ``pagesModified``.
|
|
567
|
+
"""
|
|
568
|
+
return _run(
|
|
569
|
+
"crop",
|
|
570
|
+
input=input_path,
|
|
571
|
+
output=output_path,
|
|
572
|
+
rect=json.dumps(rect),
|
|
573
|
+
pages=_int_csv(pages),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def reorder(
|
|
578
|
+
input_path: str,
|
|
579
|
+
output_path: str,
|
|
580
|
+
order: Sequence[int],
|
|
581
|
+
) -> None:
|
|
582
|
+
"""Reorder pages. ``order`` is a sequence of 1-based page numbers."""
|
|
583
|
+
_run(
|
|
584
|
+
"reorder",
|
|
585
|
+
input=input_path,
|
|
586
|
+
output=output_path,
|
|
587
|
+
order=",".join(str(p) for p in order),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def delete_pages(
|
|
592
|
+
input_path: str,
|
|
593
|
+
output_path: str,
|
|
594
|
+
pages: Sequence[int],
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Delete specified pages (1-based) from a PDF."""
|
|
597
|
+
_run(
|
|
598
|
+
"delete-pages",
|
|
599
|
+
input=input_path,
|
|
600
|
+
output=output_path,
|
|
601
|
+
pages=_int_csv(pages),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
606
|
+
# Images
|
|
607
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
608
|
+
|
|
609
|
+
def find_images(
|
|
610
|
+
input_path: str,
|
|
611
|
+
*,
|
|
612
|
+
output_dir: str | None = None,
|
|
613
|
+
) -> Any:
|
|
614
|
+
"""Find all images in a PDF.
|
|
615
|
+
|
|
616
|
+
Parameters
|
|
617
|
+
----------
|
|
618
|
+
output_dir : str, optional
|
|
619
|
+
If provided, extracted image files are saved there.
|
|
620
|
+
|
|
621
|
+
Returns a namespace with ``images`` (list), ``totalImages``,
|
|
622
|
+
``pagesSearched``.
|
|
623
|
+
"""
|
|
624
|
+
kwargs: dict[str, Any] = {"input": input_path}
|
|
625
|
+
if output_dir:
|
|
626
|
+
kwargs["output_dir"] = output_dir
|
|
627
|
+
return _run("find-images", **kwargs)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def replace_image(
|
|
631
|
+
input_path: str,
|
|
632
|
+
output_path: str,
|
|
633
|
+
image_path: str,
|
|
634
|
+
*,
|
|
635
|
+
page_range: Sequence[int] | None = None,
|
|
636
|
+
image_indices: Sequence[int] | None = None,
|
|
637
|
+
scale_mode: str | None = None,
|
|
638
|
+
) -> Any:
|
|
639
|
+
"""Replace images in a PDF with a new image (JPEG or PNG).
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
scale_mode : str, optional
|
|
644
|
+
"match_original_size", "scale_to_fit", or "preserve_aspect_ratio".
|
|
645
|
+
|
|
646
|
+
Returns a namespace with ``imagesReplaced``, ``imagesFound``,
|
|
647
|
+
``pagesSearched``.
|
|
648
|
+
"""
|
|
649
|
+
options: dict[str, Any] = {}
|
|
650
|
+
if page_range:
|
|
651
|
+
options["pageRange"] = list(page_range)
|
|
652
|
+
if image_indices:
|
|
653
|
+
options["imageIndices"] = list(image_indices)
|
|
654
|
+
if scale_mode:
|
|
655
|
+
mode_map = {"match_original_size": 0, "preserve_aspect_ratio": 1, "scale_to_fit": 2}
|
|
656
|
+
options["scaleMode"] = mode_map.get(scale_mode.lower(), 0)
|
|
657
|
+
|
|
658
|
+
kwargs: dict[str, Any] = {
|
|
659
|
+
"input": input_path,
|
|
660
|
+
"output": output_path,
|
|
661
|
+
"image": image_path,
|
|
662
|
+
}
|
|
663
|
+
if options:
|
|
664
|
+
kwargs["options"] = json.dumps(options)
|
|
665
|
+
|
|
666
|
+
return _run("replace-image", **kwargs)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
670
|
+
# Digital signatures
|
|
671
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
672
|
+
|
|
673
|
+
def sign(
|
|
674
|
+
input_path: str,
|
|
675
|
+
output_path: str,
|
|
676
|
+
cert_path: str,
|
|
677
|
+
*,
|
|
678
|
+
cert_password: str = "",
|
|
679
|
+
reason: str | None = None,
|
|
680
|
+
location: str | None = None,
|
|
681
|
+
signer_name: str | None = None,
|
|
682
|
+
visible: bool = False,
|
|
683
|
+
page: int | None = None,
|
|
684
|
+
rect: dict[str, float] | None = None,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Digitally sign a PDF with a PKCS#12 / PFX certificate.
|
|
687
|
+
|
|
688
|
+
Parameters
|
|
689
|
+
----------
|
|
690
|
+
rect : dict, optional
|
|
691
|
+
Visible signature rectangle:
|
|
692
|
+
``{"x": float, "y": float, "width": float, "height": float}``.
|
|
693
|
+
"""
|
|
694
|
+
kwargs: dict[str, Any] = {
|
|
695
|
+
"input": input_path,
|
|
696
|
+
"output": output_path,
|
|
697
|
+
"cert": cert_path,
|
|
698
|
+
"cert_password": cert_password,
|
|
699
|
+
}
|
|
700
|
+
if reason:
|
|
701
|
+
kwargs["reason"] = reason
|
|
702
|
+
if location:
|
|
703
|
+
kwargs["location"] = location
|
|
704
|
+
if signer_name:
|
|
705
|
+
kwargs["signer_name"] = signer_name
|
|
706
|
+
if visible:
|
|
707
|
+
kwargs["visible"] = True
|
|
708
|
+
if page is not None:
|
|
709
|
+
kwargs["page"] = str(page)
|
|
710
|
+
if rect:
|
|
711
|
+
kwargs["rect"] = json.dumps(rect)
|
|
712
|
+
|
|
713
|
+
_run("sign", **kwargs)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def verify(input_path: str, *, all_signatures: bool = False) -> Any:
|
|
717
|
+
"""Verify digital signatures in a PDF.
|
|
718
|
+
|
|
719
|
+
Parameters
|
|
720
|
+
----------
|
|
721
|
+
all_signatures : bool
|
|
722
|
+
If True, returns info for every signature field.
|
|
723
|
+
|
|
724
|
+
Returns a namespace (single) or list of namespaces with ``isSigned``,
|
|
725
|
+
``signerName``, ``reason``, ``location``, ``signDate``, ``isValid``,
|
|
726
|
+
``validationMessage``.
|
|
727
|
+
"""
|
|
728
|
+
kwargs: dict[str, Any] = {"input": input_path}
|
|
729
|
+
if all_signatures:
|
|
730
|
+
kwargs["all"] = True
|
|
731
|
+
return _run("verify", **kwargs)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
735
|
+
# PDF/A
|
|
736
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
737
|
+
|
|
738
|
+
def pdfa_validate(
|
|
739
|
+
input_path: str,
|
|
740
|
+
*,
|
|
741
|
+
level: str = "2b",
|
|
742
|
+
) -> Any:
|
|
743
|
+
"""Validate PDF/A compliance. No license required.
|
|
744
|
+
|
|
745
|
+
Parameters
|
|
746
|
+
----------
|
|
747
|
+
level : str
|
|
748
|
+
Conformance level: "1b", "2b", "2u", "3b", or "3u".
|
|
749
|
+
|
|
750
|
+
Returns a namespace with ``isCompliant``, ``targetLevel``,
|
|
751
|
+
``violations``.
|
|
752
|
+
"""
|
|
753
|
+
return _run("pdfa-validate", input=input_path, level=level)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def pdfa_convert(
|
|
757
|
+
input_path: str,
|
|
758
|
+
output_path: str,
|
|
759
|
+
*,
|
|
760
|
+
level: str = "2b",
|
|
761
|
+
) -> None:
|
|
762
|
+
"""Convert a PDF to PDF/A compliance."""
|
|
763
|
+
_run("pdfa-convert", input=input_path, output=output_path, level=level)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
767
|
+
# Internal helpers
|
|
768
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
769
|
+
|
|
770
|
+
def _int_csv(values: Sequence[int] | None) -> str | None:
|
|
771
|
+
"""Convert a sequence of ints to a comma-separated string, or None."""
|
|
772
|
+
if not values:
|
|
773
|
+
return None
|
|
774
|
+
return ",".join(str(v) for v in values)
|