tpdf-client 0.1.0__py3-none-any.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,748 @@
1
+ """Drive tpdf without a shell or a GUI; requires the tpdf CLI installed separately.
2
+
3
+ Install from a checkout: uv pip install ./api/python
4
+
5
+ from tpdf_client import Tpdf
6
+ pdf = Tpdf() # or Tpdf('/path/to/tpdf-cli')
7
+ result = pdf.edit('input.pdf', 'output.pdf', [
8
+ {'op': 'rotate', 'page': 1, 'degrees': 90},
9
+ {'op': 'insert_blank', 'after': 1, 'width': 595, 'height': 842},
10
+ ])
11
+ assert result['written']
12
+ assert len(pdf.info('output.pdf')['files']) == 1
13
+
14
+ The client uses the same versioned JSON and exit codes as the CLI. run() exposes
15
+ all commands, including new commands a newer executable adds. Nonzero exits raise
16
+ CommandError by default; use check=False to examine negative verification results
17
+ or a partial split. A timeout may occur after an output was published: it is not a
18
+ rollback and must never be interpreted as proof that nothing was written.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ import json
24
+ import math
25
+ import os
26
+ from pathlib import Path
27
+ import shutil
28
+ import signal
29
+ import subprocess
30
+ from typing import Any, Mapping, Sequence
31
+
32
+ from . import reports
33
+
34
+ __all__ = ['Tpdf', 'Result', 'CommandError', 'ProtocolError', 'CommandTimeout', 'reports']
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Result:
39
+ """The report and actual process status, including partial/negative results."""
40
+
41
+ report: dict[str, Any]
42
+ exit_code: int
43
+ stderr: str
44
+
45
+ @property
46
+ def typed(self) -> Any:
47
+ """The report, for a method that names its shape in `tpdf_client.reports`.
48
+
49
+ The shape is the CLI's promise, held against its committed samples by
50
+ `test_reports.py`; nothing here checks a report against it at run time.
51
+ """
52
+ return self.report
53
+
54
+
55
+ class CommandError(RuntimeError):
56
+ """A CLI refusal, failed verification, or internal failure; report is retained."""
57
+
58
+ def __init__(self, result: Result):
59
+ self.result = result
60
+ self.report = result.report
61
+ self.exit_code = result.exit_code
62
+ error = result.report.get('error')
63
+ message = error.get('message') if isinstance(error, dict) else None
64
+ super().__init__(message or result.stderr.strip() or f'tpdf exited {result.exit_code}')
65
+
66
+
67
+ class ProtocolError(RuntimeError):
68
+ """The executable did not return the supported JSON contract."""
69
+
70
+
71
+ class CommandTimeout(TimeoutError):
72
+ """The timeout expired. Outputs may exist; this does not imply rollback."""
73
+
74
+
75
+ def _stop(process: subprocess.Popen[bytes]) -> None:
76
+ # The group belongs to this invocation, never to the calling test runner.
77
+ # On Windows taskkill must see the live parent to enumerate its descendants.
78
+ if os.name == 'nt':
79
+ subprocess.run(
80
+ ['taskkill', '/PID', str(process.pid), '/T', '/F'],
81
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
82
+ timeout=10,
83
+ )
84
+ else:
85
+ try:
86
+ os.killpg(process.pid, signal.SIGKILL)
87
+ except ProcessLookupError:
88
+ pass
89
+
90
+
91
+ class Tpdf:
92
+ """A stateless client. Each call runs an isolated CLI process and its workers.
93
+
94
+ Relative document paths are resolved against cwd, when supplied. Passwords
95
+ are passed in a child-only environment variable, never on the command line.
96
+ No server is started and no global environment variable is changed.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ executable: str | os.PathLike[str] | None = None,
102
+ *,
103
+ timeout: float = 120,
104
+ cwd: str | os.PathLike[str] | None = None,
105
+ ):
106
+ if not math.isfinite(timeout) or timeout <= 0:
107
+ raise ValueError('timeout must be a finite positive number of seconds')
108
+ found = os.fspath(executable) if executable is not None else (
109
+ shutil.which('tpdf-cli') or shutil.which('tpdf')
110
+ )
111
+ if not found:
112
+ raise FileNotFoundError('tpdf CLI not found; pass executable= or install it on PATH')
113
+ # Resolve a caller-supplied relative executable before changing the child cwd.
114
+ self.executable = str(Path(shutil.which(found) or found).resolve())
115
+ self.timeout = timeout
116
+ self.cwd = os.fspath(cwd) if cwd is not None else None
117
+
118
+ def run(
119
+ self,
120
+ command: str,
121
+ *arguments: str | os.PathLike[str],
122
+ input_json: Any = None,
123
+ password: str | None = None,
124
+ new_password: str | None = None,
125
+ check: bool = True,
126
+ ) -> Result:
127
+ """Run any JSON-capable command with literal arguments (never shell syntax).
128
+
129
+ Use input_json with commands accepting stdin: edit --plan -, fill --values -.
130
+ Keep reports on stdout; text -o/--output is rejected because it redirects
131
+ that report into a file. Read text through text() and write it in the caller.
132
+ """
133
+ if not command or command.startswith('-'):
134
+ raise ValueError('command must be a subcommand name, such as info or edit')
135
+ args = [os.fspath(arg) for arg in arguments]
136
+ flags = args[:args.index('--')] if '--' in args else args
137
+ if command == 'text' and any(arg in ('-o', '--output') for arg in flags):
138
+ raise ValueError('text output must remain on stdout for the API')
139
+ env = os.environ.copy()
140
+ options = ['--json']
141
+ if password is not None:
142
+ env['TPDF_API_DOCUMENT_PASSWORD'] = password
143
+ options += ['--password-env', 'TPDF_API_DOCUMENT_PASSWORD']
144
+ if new_password is not None:
145
+ env['TPDF_API_NEW_PASSWORD'] = new_password
146
+ options += ['--new-password-env', 'TPDF_API_NEW_PASSWORD']
147
+ payload = None if input_json is None else json.dumps(
148
+ input_json, ensure_ascii=False, allow_nan=False, separators=(',', ':'),
149
+ ).encode('utf-8')
150
+ process = subprocess.Popen(
151
+ [self.executable, command, *options, *args],
152
+ stdin=subprocess.PIPE if payload is not None else subprocess.DEVNULL,
153
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
154
+ env=env, cwd=self.cwd, start_new_session=os.name != 'nt',
155
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0,
156
+ )
157
+ try:
158
+ stdout, stderr = process.communicate(payload, timeout=self.timeout)
159
+ except (subprocess.TimeoutExpired, KeyboardInterrupt) as error:
160
+ try:
161
+ _stop(process)
162
+ except (OSError, subprocess.SubprocessError):
163
+ # Keep cleanup bounded even if taskkill is unavailable or denied.
164
+ pass
165
+ finally:
166
+ process.kill()
167
+ try:
168
+ process.communicate(timeout=5)
169
+ except subprocess.TimeoutExpired:
170
+ # A descendant may have retained a pipe after the parent died.
171
+ # Closing our copies keeps the caller's deadline bounded.
172
+ for stream in (process.stdin, process.stdout, process.stderr):
173
+ if stream is not None:
174
+ stream.close()
175
+ process.wait(timeout=5)
176
+ if isinstance(error, KeyboardInterrupt):
177
+ raise
178
+ raise CommandTimeout(
179
+ f'tpdf {command} exceeded {self.timeout} seconds; outputs may already exist'
180
+ ) from None
181
+ try:
182
+ report = json.loads(stdout.decode('utf-8'))
183
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
184
+ raise ProtocolError(
185
+ f'tpdf {command} exited {process.returncode} without a valid JSON report'
186
+ ) from error
187
+ if not isinstance(report, dict) or type(report.get('schema')) is not int or report['schema'] != 1:
188
+ raise ProtocolError('unsupported or missing tpdf report schema; expected 1')
189
+ if report.get('command') != command:
190
+ raise ProtocolError(f'tpdf report command does not match {command}')
191
+ result = Result(report, process.returncode, stderr.decode('utf-8', errors='replace'))
192
+ if check and result.exit_code != 0:
193
+ raise CommandError(result)
194
+ return result
195
+
196
+ def help(self, command: str | None = None) -> reports.HelpReport:
197
+ """Discover the executable's commands and application version.
198
+
199
+ With command, the report lists that command alone.
200
+ """
201
+ if command is None:
202
+ return self.run('help').typed
203
+ if not command or command.startswith('-'):
204
+ raise ValueError('command must be a subcommand name, such as info or edit')
205
+ return self.run('help', command).typed
206
+
207
+ def info(self, *paths: str | os.PathLike[str], password: str | None = None) -> reports.InfoReport:
208
+ """Read document information."""
209
+ return self.run('info', '--', *paths, password=password).typed
210
+
211
+ def text(self, path: str | os.PathLike[str], *, password: str | None = None) -> reports.TextReport:
212
+ """Extract text and its reading order."""
213
+ return self.run('text', '--', path, password=password).typed
214
+
215
+ def search(
216
+ self, *paths: str | os.PathLike[str],
217
+ texts: Sequence[str] = (), patterns: Sequence[str] = (),
218
+ pages: str | None = None, case_sensitive: bool = False, whole_word: bool = False,
219
+ password: str | None = None,
220
+ ) -> reports.SearchReport:
221
+ """Find text or regex matches, as the viewer's find and redact() find them.
222
+
223
+ Returns one entry per document in report['files'], each with its matches
224
+ (page, before, hit, after) and pages_without_text: a scanned page has
225
+ nothing to search, which is not the same as holding no match.
226
+
227
+ Finding nothing is an answer, not an error: the CLI's exit 1 is returned
228
+ as a report whose files have empty matches. A document that could not be
229
+ read raises CommandError, and the report keeps the other documents.
230
+ """
231
+ if not paths:
232
+ raise ValueError('search needs at least one document')
233
+ args = []
234
+ for flag, queries in [('--text', texts), ('--pattern', patterns)]:
235
+ if isinstance(queries, (str, bytes)):
236
+ raise TypeError(f'{flag} queries must be a sequence of strings, not one string')
237
+ for query in queries:
238
+ if not isinstance(query, str):
239
+ raise TypeError(f'{flag} queries must be strings')
240
+ args += [flag, query]
241
+ if not args:
242
+ raise ValueError('search needs something to find: texts= or patterns=')
243
+ if pages is not None:
244
+ args += ['--pages', pages]
245
+ for enabled, flag in [(case_sensitive, '--case-sensitive'), (whole_word, '--whole-word')]:
246
+ if enabled:
247
+ args.append(flag)
248
+ result = self.run('search', *args, '--', *paths, password=password, check=False)
249
+ if result.exit_code not in (0, 1):
250
+ raise CommandError(result)
251
+ return result.typed
252
+
253
+ def fields(self, path: str | os.PathLike[str], *, password: str | None = None) -> reports.FieldsReport:
254
+ """Inspect form fields and the answers they accept."""
255
+ return self.run('fields', '--', path, password=password).typed
256
+
257
+ def comments(self, path: str | os.PathLike[str], *, password: str | None = None) -> reports.CommentsReport:
258
+ """Read annotations; incomplete scans raise CommandError with their report."""
259
+ return self.run('comments', '--', path, password=password).typed
260
+
261
+ def text_runs(self, path: str | os.PathLike[str], *, page: int = 1, password: str | None = None) -> reports.TextRunsReport:
262
+ """Inspect original text runs and the revision required for replacement."""
263
+ return self.run('text-runs', '--page', str(page), '--', path, password=password).typed
264
+
265
+ def hidden(
266
+ self, path: str | os.PathLike[str], *, pages: str | None = None, password: str | None = None,
267
+ ) -> reports.HiddenReport:
268
+ """List text that is in a document and not visible on its pages.
269
+
270
+ The check for a document redacted elsewhere: words under a black box,
271
+ under an annotation, in the background's colour, never painted, or
272
+ outside the page. Each entry of report['found'] has the page, the words
273
+ and where they are.
274
+
275
+ Finding something is an answer, not an error: the CLI's exit 1 is
276
+ returned as a report whose found is not empty. An empty found means
277
+ nothing was found, not that the document is clean: without_text lists
278
+ the pages that have no text and were not compared, and unjudged counts
279
+ the characters that could not be decided.
280
+ """
281
+ args = [] if pages is None else ['--pages', pages]
282
+ result = self.run('hidden', *args, '--', path, password=password, check=False)
283
+ if result.exit_code not in (0, 1):
284
+ raise CommandError(result)
285
+ return result.typed
286
+
287
+ def render(
288
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
289
+ page: int = 1, dpi: int = 144, force: bool = False, password: str | None = None,
290
+ ) -> reports.RenderReport:
291
+ """Render one page to PNG for visual assertions, without opening a window."""
292
+ args = ['-o', os.fspath(output), '--page', str(page), '--dpi', str(dpi)]
293
+ if force:
294
+ args.append('--force')
295
+ return self.run('render', *args, '--', source, password=password).typed
296
+
297
+ def ocr(
298
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
299
+ pages: str | None = None, languages: Sequence[str] = (), force: bool = False,
300
+ invalidate_signatures: bool = False, password: str | None = None,
301
+ ) -> reports.OcrReport:
302
+ """Write a copy whose scanned pages can be searched and selected.
303
+
304
+ Pages without text are read by the operating system's text recogniser;
305
+ pages that already have text are left as they are. `languages` are
306
+ BCP-47 tags such as "de-DE", most preferred first.
307
+ """
308
+ args = ['-o', os.fspath(output)]
309
+ if pages is not None:
310
+ args += ['--pages', pages]
311
+ for language in languages:
312
+ args += ['--language', language]
313
+ if force:
314
+ args.append('--force')
315
+ if invalidate_signatures:
316
+ args.append('--invalidate-signatures')
317
+ return self.run('ocr', *args, '--', source, password=password).typed
318
+
319
+ def images(
320
+ self, pictures: Sequence[str | os.PathLike[str]], output: str | os.PathLike[str], *,
321
+ paper: str | None = None, dpi: int | None = None, force: bool = False,
322
+ ) -> reports.ImagesReport:
323
+ """Write a document with one page for each PNG or JPEG picture, in order.
324
+
325
+ A page is the picture's own size unless `paper` is "a4" or "letter";
326
+ `dpi` replaces the resolution each file states.
327
+ """
328
+ if isinstance(pictures, (str, bytes, os.PathLike)):
329
+ raise TypeError('pictures must be a sequence of paths, not one path')
330
+ args = ['-o', os.fspath(output)]
331
+ if paper is not None:
332
+ args += ['--paper', paper]
333
+ if dpi is not None:
334
+ args += ['--dpi', str(dpi)]
335
+ if force:
336
+ args.append('--force')
337
+ return self.run('images', *args, '--', *pictures).typed
338
+
339
+ def protect(
340
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str],
341
+ new_password: str, *, force: bool = False,
342
+ invalidate_signatures: bool = False, password: str | None = None,
343
+ ) -> reports.ProtectReport:
344
+ """Write a copy that needs `new_password` to open, encrypted with AES-256.
345
+
346
+ `password` opens a source that already has one; the copy gets the new
347
+ one instead. Neither password is put on the command line.
348
+ """
349
+ args = ['-o', os.fspath(output)]
350
+ if force:
351
+ args.append('--force')
352
+ if invalidate_signatures:
353
+ args.append('--invalidate-signatures')
354
+ return self.run(
355
+ 'protect', *args, '--', source, password=password, new_password=new_password,
356
+ ).typed
357
+
358
+ def compress(
359
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str] | None = None, *,
360
+ pictures: str | None = None, dpi: int | None = None, quality: int | None = None,
361
+ jpeg: bool = True, preview: str | os.PathLike[str] | None = None,
362
+ force: bool = False, invalidate_signatures: bool = False, password: str | None = None,
363
+ ) -> reports.CompressReport:
364
+ """Write a smaller copy, or with no `output` say what one would come to.
365
+
366
+ On its own nothing a reader sees changes. `pictures` names a preset
367
+ (`screen`, `balanced`, `print`) that scales pictures down and stores
368
+ photographs as JPEG; `dpi` and `quality` set those numbers directly,
369
+ and `jpeg=False` keeps lossless pictures lossless. `preview` writes one
370
+ part of one page before and after as a PNG. A copy that would not be
371
+ smaller is refused.
372
+ """
373
+ args: list[str] = ['--dry-run'] if output is None else ['-o', os.fspath(output)]
374
+ if pictures is not None:
375
+ args += ['--pictures', pictures]
376
+ for flag, number in (('--dpi', dpi), ('--quality', quality)):
377
+ if number is not None:
378
+ if isinstance(number, bool) or not isinstance(number, int):
379
+ raise TypeError(f'{flag[2:]} is a whole number')
380
+ args += [flag, str(number)]
381
+ if not jpeg:
382
+ args.append('--no-jpeg')
383
+ if preview is not None:
384
+ args += ['--preview', os.fspath(preview)]
385
+ if force:
386
+ args.append('--force')
387
+ if invalidate_signatures:
388
+ args.append('--invalidate-signatures')
389
+ return self.run('compress', *args, '--', source, password=password).typed
390
+
391
+ def add_fields(
392
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str],
393
+ fields: Sequence[Mapping[str, Any]], *, force: bool = False,
394
+ invalidate_signatures: bool = False, password: str | None = None,
395
+ ) -> reports.FormReport:
396
+ """Write a copy with form fields added, which `fill` can then answer.
397
+
398
+ Each field is a mapping with `name`, `kind` (`text`, `multiline`,
399
+ `checkbox` or `dropdown`), `page` counted from 1 and `rect` as `[left,
400
+ top, width, height]` in points from the page's top-left corner; a
401
+ dropdown also has `options`, the list of its choices; `tooltip`,
402
+ `required` and `max_length` are optional. One field that cannot be
403
+ added means none is, and the error names every problem.
404
+ """
405
+ args = ['-o', os.fspath(output), '--fields', '-']
406
+ if force:
407
+ args.append('--force')
408
+ if invalidate_signatures:
409
+ args.append('--invalidate-signatures')
410
+ return self.run(
411
+ 'form', *args, '--', source, input_json=[dict(field) for field in fields],
412
+ password=password,
413
+ ).typed
414
+
415
+ def unprotect(
416
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str],
417
+ password: str, *, force: bool = False, invalidate_signatures: bool = False,
418
+ ) -> reports.ProtectReport:
419
+ """Write a copy that opens without a password, from a source that needs one.
420
+
421
+ Refused for a source that opens without a password: restrictions such a
422
+ document carries are left in place.
423
+ """
424
+ args = ['-o', os.fspath(output)]
425
+ if force:
426
+ args.append('--force')
427
+ if invalidate_signatures:
428
+ args.append('--invalidate-signatures')
429
+ return self.run('unprotect', *args, '--', source, password=password).typed
430
+
431
+ def fill(
432
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str],
433
+ values: Mapping[str, Any], *, force: bool = False, password: str | None = None,
434
+ ) -> reports.FillReport:
435
+ """Fill fields from a Python mapping without an intermediate JSON file."""
436
+ args = ['-o', os.fspath(output), '--values', '-']
437
+ if force:
438
+ args.append('--force')
439
+ return self.run('fill', *args, '--', source, input_json=dict(values), password=password).typed
440
+
441
+ def edit(
442
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str],
443
+ operations: Sequence[Mapping[str, Any]], *, dry_run: bool = False,
444
+ force: bool = False, invalidate_signatures: bool = False,
445
+ password: str | None = None,
446
+ ) -> reports.EditReport:
447
+ """Apply ordered schema-1 edits. A rejected plan publishes no output."""
448
+ args = ['--plan', '-', '-o', os.fspath(output)]
449
+ if dry_run:
450
+ args.append('--dry-run')
451
+ if force:
452
+ args.append('--force')
453
+ if invalidate_signatures:
454
+ args.append('--invalidate-signatures')
455
+ args += ['--', os.fspath(source)]
456
+ return self.run(
457
+ 'edit', *args, input_json={'schema': 1, 'operations': list(operations)},
458
+ password=password,
459
+ ).typed
460
+
461
+ def mark_matches(
462
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
463
+ texts: Sequence[str] = (), patterns: Sequence[str] = (),
464
+ kind: str = 'highlight', color: Sequence[float] | None = None,
465
+ pages: str | None = None, case_sensitive: bool = False, whole_word: bool = False,
466
+ force: bool = False, invalidate_signatures: bool = False,
467
+ password: str | None = None,
468
+ ) -> reports.EditReport | None:
469
+ """Highlight, underline, strike out or squiggle every match, into output.
470
+
471
+ search() finds the matches and edit() marks each of their rectangles.
472
+ Returns the edit report, or None when nothing matched: no file is
473
+ written then, so an output that exists means something was marked.
474
+ color is red, green and blue from 0 to 1; without it edit's default applies.
475
+
476
+ A match whose characters have no position on the page raises ValueError
477
+ before anything is written, rather than being left unmarked in silence.
478
+ """
479
+ if kind not in ('highlight', 'underline', 'strikeout', 'squiggly'):
480
+ raise ValueError('kind must be highlight, underline, strikeout or squiggly')
481
+ if color is not None:
482
+ color = [float(channel) for channel in color]
483
+ if len(color) != 3 or not all(0 <= channel <= 1 for channel in color):
484
+ raise ValueError('color must be red, green and blue from 0 to 1')
485
+ found = self.search(source, texts=texts, patterns=patterns, pages=pages,
486
+ case_sensitive=case_sensitive, whole_word=whole_word,
487
+ password=password)['files'][0]
488
+ operations = []
489
+ for match in found['matches']:
490
+ if not match['rects']:
491
+ raise ValueError(
492
+ f"page {match['page']}: {match['hit']!r} matched and has no position, "
493
+ 'so it cannot be marked'
494
+ )
495
+ for area in match['rects']:
496
+ operation = {'op': 'annotate', 'page': area['page'], 'kind': kind, 'rect': area['rect']}
497
+ if color is not None:
498
+ operation['color'] = color
499
+ operations.append(operation)
500
+ if not operations:
501
+ return None
502
+ return self.edit(source, output, operations, force=force,
503
+ invalidate_signatures=invalidate_signatures, password=password)
504
+
505
+ def verify(self, *paths: str | os.PathLike[str], strict: bool = False) -> reports.VerifyReport:
506
+ """Inspect signatures. With strict=True, unsigned/untrusted files raise CommandError.
507
+
508
+ The exception retains the full verification report and exit code. Use
509
+ run('verify', '--strict', '--', *paths, check=False) for a Result instead.
510
+ """
511
+ args = ['--strict'] if strict else []
512
+ return self.run('verify', *args, '--', *paths).typed
513
+
514
+ def _pages(
515
+ self, command: str, sources: Sequence[str | os.PathLike[str]],
516
+ output: str | os.PathLike[str], options: Sequence[str], *,
517
+ force: bool, invalidate_signatures: bool, password: str | None,
518
+ ) -> reports.PagesReport:
519
+ args = ['-o', os.fspath(output), *options]
520
+ if force:
521
+ args.append('--force')
522
+ if invalidate_signatures:
523
+ args.append('--invalidate-signatures')
524
+ return self.run(command, *args, '--', *sources, password=password).typed
525
+
526
+ def merge(
527
+ self, sources: Sequence[str | os.PathLike[str]], output: str | os.PathLike[str], *,
528
+ force: bool = False, invalidate_signatures: bool = False, password: str | None = None,
529
+ ) -> reports.PagesReport:
530
+ """Combine at least two documents in order. Only the first may be encrypted."""
531
+ if isinstance(sources, (str, bytes, os.PathLike)):
532
+ raise TypeError('sources must be a sequence of document paths, not one path')
533
+ return self._pages('merge', sources, output, [], force=force,
534
+ invalidate_signatures=invalidate_signatures, password=password)
535
+
536
+ def extract(
537
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
538
+ pages: str, force: bool = False, invalidate_signatures: bool = False,
539
+ password: str | None = None,
540
+ ) -> reports.PagesReport:
541
+ """Copy a page range (e.g. '1-3,7'), in document order, each page once."""
542
+ return self._pages('extract', [source], output, ['--pages', pages], force=force,
543
+ invalidate_signatures=invalidate_signatures, password=password)
544
+
545
+ def split(
546
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
547
+ every: int = 1, force: bool = False, invalidate_signatures: bool = False,
548
+ password: str | None = None,
549
+ ) -> reports.PagesReport:
550
+ """Write groups as output-1.pdf, output-2.pdf, etc.; report lists actual paths.
551
+
552
+ A publication failure can leave some parts written. CommandError.typed
553
+ retains those paths; do not interpret an exception as a rollback.
554
+ """
555
+ return self._pages('split', [source], output, ['--every', str(every)], force=force,
556
+ invalidate_signatures=invalidate_signatures, password=password)
557
+
558
+ def rotate(
559
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
560
+ degrees: int, pages: str | None = None, force: bool = False,
561
+ invalidate_signatures: bool = False, password: str | None = None,
562
+ ) -> reports.PagesReport:
563
+ """Turn selected pages clockwise by 90, 180 or 270 degrees; default all pages."""
564
+ options = ['--degrees', str(degrees)]
565
+ if pages is not None:
566
+ options += ['--pages', pages]
567
+ return self._pages('rotate', [source], output, options, force=force,
568
+ invalidate_signatures=invalidate_signatures, password=password)
569
+
570
+ def crop(
571
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
572
+ rect: Sequence[float], pages: str | None = None, force: bool = False,
573
+ invalidate_signatures: bool = False, password: str | None = None,
574
+ ) -> reports.PagesReport:
575
+ """Set visible x,y,width,height in display points from top-left.
576
+
577
+ Cropping hides content; it does not redact it. The CLI validates geometry.
578
+ """
579
+ if isinstance(rect, (str, bytes)) or len(rect) != 4:
580
+ raise ValueError('rect must contain four numbers: x, y, width, height')
581
+ options = ['--rect', ','.join(str(value) for value in rect)]
582
+ if pages is not None:
583
+ options += ['--pages', pages]
584
+ return self._pages('crop', [source], output, options, force=force,
585
+ invalidate_signatures=invalidate_signatures, password=password)
586
+
587
+ def redact(
588
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str] | None = None, *,
589
+ texts: Sequence[str] = (), patterns: Sequence[str] = (),
590
+ regions: Sequence[Mapping[str, Any]] | None = None,
591
+ pages: str | None = None, case_sensitive: bool = False, dry_run: bool = False,
592
+ force: bool = False, invalidate_signatures: bool = False,
593
+ fill: str = 'black',
594
+ password: str | None = None, check: bool = True,
595
+ ) -> reports.RedactReport:
596
+ """Remove text, regex matches or rectangles, using the CLI's verified writer.
597
+
598
+ Regions are {'page': 1, 'rect': [x, y, width, height]} in display points
599
+ from top-left, sent through stdin. pages limits searches only; regions
600
+ name their own pages. Matching ignores case unless case_sensitive=True.
601
+ A dry run (output optional) writes nothing and has verified=None.
602
+ fill is the colour of the boxes drawn over what went: 'black', 'white'
603
+ or 'red'. White boxes cannot be seen on white paper.
604
+
605
+ A written but unverified copy raises CommandError with exit_code=1;
606
+ its report and file remain available. check=False returns that report
607
+ directly: inspect written, verified and reasons. Exit 0 alone does not
608
+ prove a redaction: no matches also writes nothing with verified=None.
609
+ """
610
+ if fill not in ('black', 'white', 'red'):
611
+ raise ValueError("fill must be 'black', 'white' or 'red'")
612
+ args = []
613
+ if output is not None:
614
+ args += ['-o', os.fspath(output)]
615
+ if fill != 'black':
616
+ args += ['--fill', fill]
617
+ for flag, queries in [('--text', texts), ('--pattern', patterns)]:
618
+ if isinstance(queries, (str, bytes)):
619
+ raise TypeError(f'{flag} queries must be a sequence of strings, not one string')
620
+ for query in queries:
621
+ if not isinstance(query, str):
622
+ raise TypeError(f'{flag} queries must be strings')
623
+ args += [flag, query]
624
+ payload = None
625
+ if regions is not None:
626
+ if isinstance(regions, (str, bytes, Mapping)):
627
+ raise TypeError('regions must be a sequence of page/rect mappings')
628
+ payload = [dict(region) for region in regions]
629
+ args += ['--regions', '-']
630
+ if pages is not None:
631
+ args += ['--pages', pages]
632
+ for enabled, flag in [(case_sensitive, '--case-sensitive'), (dry_run, '--dry-run'),
633
+ (force, '--force'), (invalidate_signatures, '--invalidate-signatures')]:
634
+ if enabled:
635
+ args.append(flag)
636
+ return self.run('redact', *args, '--', source, input_json=payload,
637
+ password=password, check=check).typed
638
+
639
+ def identities(self) -> reports.IdentitiesReport:
640
+ """List usable OS-held signing certificates and rejected ones with reasons.
641
+
642
+ Enumeration does not sign. Pass a chosen certificate's id to sign(); the
643
+ client never chooses the first available identity automatically.
644
+ """
645
+ return self.run('identities').typed
646
+
647
+ def sign(
648
+ self, source: str | os.PathLike[str], output: str | os.PathLike[str], *,
649
+ identity: str, rect: Sequence[float] | None = None, page: int | None = None,
650
+ anchor: str | None = None, size: Sequence[float] | None = None,
651
+ offset: Sequence[float] | None = None, anchor_match: int | None = None,
652
+ no_image: bool = False, image: str | os.PathLike[str] | None = None,
653
+ lines: Sequence[str] | None = None,
654
+ text: Sequence[str] | None = None, date_format: str | None = None,
655
+ reason: str | None = None, location: str | None = None,
656
+ contact: str | None = None,
657
+ hide: Sequence[str] | None = None,
658
+ timestamp: str | None = None, long_term: bool = False, force: bool = False,
659
+ ) -> reports.SignReport:
660
+ """Sign with an explicitly selected OS-held certificate; no key is exported.
661
+
662
+ Use identities()['usable'] to select its SHA-256 id (or its sha1
663
+ thumbprint, or exact subject).
664
+ rect=[x,y,width,height] creates a visible signature, in display points
665
+ from top-left; page defaults to 1. anchor with size=[width, height]
666
+ creates one beside text on the page instead: its top-left corner is the
667
+ text's, moved by offset=[dx, dy]; text found more than once is refused
668
+ unless anchor_match says which, counted from 1. rect and anchor
669
+ together are refused. Other appearance options require rect;
670
+ reason, location and contact do not.
671
+ lines selects any of 'label', 'name', 'date'; [] hides all three.
672
+ Visible signatures use the saved image unless no_image=True. image names
673
+ a PNG or JPEG file to draw instead, for this signature only: the saved
674
+ image is neither read nor changed. image and no_image=True together are
675
+ refused. A file that is missing or is not a usable image is a refusal,
676
+ before any certificate or key is asked for.
677
+
678
+ text gives the lines to draw instead of the standard three, one string
679
+ a line: '{name}', '{date}', '{reason}' and '{location}' are filled in,
680
+ and '{{' and '}}' are a brace each. text and lines together are refused,
681
+ and so are text and hide. date_format writes the date with the tokens
682
+ YYYY, MM, DD, HH, mm and ss; the time is UTC.
683
+
684
+ reason, location and contact are written to the signature, visible or
685
+ not. A visible signature draws the reason and location as lines of it;
686
+ with text they are drawn only where the text asks for them. contact is
687
+ never drawn. hide names which of 'reason', 'location' are written without being
688
+ drawn; nothing is hidden unless it is named.
689
+
690
+ timestamp selects a CLI authority name or URL and explicitly enables its
691
+ network request. long_term requires timestamp and asks certificate
692
+ authorities for revocation evidence. Neither is enabled by default.
693
+ Encrypted inputs are not supported by signing. The OS may prompt for
694
+ key access; configure the client's timeout for interactive use.
695
+ """
696
+ if not isinstance(identity, str) or not identity.strip():
697
+ raise ValueError('identity must name an explicitly selected certificate')
698
+ args = ['-o', os.fspath(output), '--identity', identity]
699
+ if rect is not None:
700
+ if isinstance(rect, (str, bytes)) or len(rect) != 4:
701
+ raise ValueError('rect must contain four numbers: x, y, width, height')
702
+ args += ['--visible', '--rect', ','.join(str(value) for value in rect)]
703
+ if anchor is not None:
704
+ if rect is not None:
705
+ raise ValueError('rect says where the signature goes and anchor finds where; give one')
706
+ if size is None or isinstance(size, (str, bytes)) or len(size) != 2:
707
+ raise ValueError('anchor needs size: two numbers, width and height')
708
+ args += ['--visible', '--anchor', anchor, '--size', ','.join(str(value) for value in size)]
709
+ if offset is not None:
710
+ if isinstance(offset, (str, bytes)) or len(offset) != 2:
711
+ raise ValueError('offset must contain two numbers: dx, dy')
712
+ args += ['--offset', ','.join(str(value) for value in offset)]
713
+ if anchor_match is not None:
714
+ args += ['--anchor-match', str(anchor_match)]
715
+ elif size is not None or offset is not None or anchor_match is not None:
716
+ raise ValueError('size, offset and anchor_match describe a signature placed with anchor')
717
+ if page is not None:
718
+ args += ['--page', str(page)]
719
+ if image is not None and no_image:
720
+ raise ValueError('image names the image to draw and no_image draws none; give one')
721
+ if no_image:
722
+ args.append('--no-image')
723
+ if image is not None:
724
+ args += ['--image', os.fspath(image)]
725
+ if lines is not None:
726
+ if isinstance(lines, (str, bytes)):
727
+ raise TypeError('lines must be a sequence of label, name, date')
728
+ args += ['--lines', ','.join(lines)]
729
+ if hide is not None:
730
+ if isinstance(hide, (str, bytes)):
731
+ raise TypeError('hide must be a sequence of reason, location')
732
+ args += ['--hide', ','.join(hide)]
733
+ if text is not None:
734
+ if isinstance(text, (str, bytes)):
735
+ raise TypeError('text must be a sequence of lines')
736
+ for line in text:
737
+ args += ['--text', line]
738
+ if date_format is not None:
739
+ args += ['--date-format', date_format]
740
+ for flag, value in [('--reason', reason), ('--location', location), ('--contact', contact),
741
+ ('--timestamp', timestamp)]:
742
+ if value is not None:
743
+ args += [flag, value]
744
+ if long_term:
745
+ args.append('--long-term')
746
+ if force:
747
+ args.append('--force')
748
+ return self.run('sign', *args, '--', source).typed