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.
- tpdf_client/__init__.py +748 -0
- tpdf_client/py.typed +0 -0
- tpdf_client/reports.py +1382 -0
- tpdf_client-0.1.0.dist-info/METADATA +64 -0
- tpdf_client-0.1.0.dist-info/RECORD +8 -0
- tpdf_client-0.1.0.dist-info/WHEEL +5 -0
- tpdf_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- tpdf_client-0.1.0.dist-info/top_level.txt +1 -0
tpdf_client/__init__.py
ADDED
|
@@ -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
|