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.
@@ -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)