PyPDFForm 5.2.3__tar.gz → 5.2.5__tar.gz
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.
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PKG-INFO +1 -1
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/__init__.py +1 -1
- pypdfform-5.2.5/PyPDFForm/lib/egress.py +265 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/utils.py +76 -1
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/wrapper.py +46 -18
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/PKG-INFO +1 -1
- {pypdfform-5.2.3 → pypdfform-5.2.5}/pyproject.toml +1 -1
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_bulk_create_fields.py +2 -2
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_create_widget.py +16 -16
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_draw_elements.py +2 -2
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_dropdown.py +2 -2
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_functional.py +50 -14
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_generate_appearance_streams.py +27 -28
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_need_appearances.py +5 -6
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_paragraph.py +2 -2
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_signature.py +3 -3
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_widget_attr_trigger.py +3 -3
- pypdfform-5.2.3/PyPDFForm/lib/egress.py +0 -116
- {pypdfform-5.2.3 → pypdfform-5.2.5}/LICENSE +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/common.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/create.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/entry.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/inspect.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/remove.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/root.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/schemas/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/schemas/create.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/schemas/update.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/cli/update.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/adapter.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/base.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/link.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/stamp.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/text.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/annotations/text_markup.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/assets/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/assets/bedrock.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/assets/blank.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/constants.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/coordinate.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/deprecation.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/filler.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/font.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/hooks.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/image.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/base.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/checkbox.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/dropdown.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/image.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/radio.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/signature.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/middleware/text.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/patterns.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/circle.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/ellipse.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/image.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/line.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/rect.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/raw/text.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/template.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/types.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/watermark.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/__init__.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/base.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/checkbox.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/dropdown.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/image.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/radio.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/signature.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm/lib/widgets/text.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/SOURCES.txt +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/dependency_links.txt +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/entry_points.txt +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/requires.txt +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/PyPDFForm.egg-info/top_level.txt +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/README.md +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/setup.cfg +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_extract_middleware_attributes.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_fill_max_length_text_field.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_font_widths.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_js.py +0 -0
- {pypdfform-5.2.3 → pypdfform-5.2.5}/tests/test_use_full_widget_name.py +0 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A module for egress functions.
|
|
4
|
+
|
|
5
|
+
This module provides functionalities that prepare the final PDF for output (egress),
|
|
6
|
+
ensuring that it is properly formatted and ready for the end-user. This includes
|
|
7
|
+
managing appearance streams (so form fields display correctly after being filled),
|
|
8
|
+
handling the /NeedAppearances flag, and preserving or updating document-level
|
|
9
|
+
properties like metadata, title, and OpenAction scripts. It can also rebuild the
|
|
10
|
+
AcroForm `/Fields` array from the widget annotations present on each page. These
|
|
11
|
+
functions are typically called right before the final PDF byte stream is returned by
|
|
12
|
+
the wrapper module.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from io import BytesIO
|
|
17
|
+
from warnings import catch_warnings, filterwarnings
|
|
18
|
+
|
|
19
|
+
from pikepdf import Pdf
|
|
20
|
+
from pypdf import PdfReader, PdfWriter
|
|
21
|
+
from pypdf.generic import ArrayObject, DictionaryObject, NameObject, TextStringObject
|
|
22
|
+
|
|
23
|
+
from .constants import (
|
|
24
|
+
JS,
|
|
25
|
+
XFA,
|
|
26
|
+
AcroForm,
|
|
27
|
+
Annots,
|
|
28
|
+
Fields,
|
|
29
|
+
JavaScript,
|
|
30
|
+
OpenAction,
|
|
31
|
+
Parent,
|
|
32
|
+
Root,
|
|
33
|
+
S,
|
|
34
|
+
Title,
|
|
35
|
+
)
|
|
36
|
+
from .template import get_widget_key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=128)
|
|
40
|
+
def appearance_streams_handler(pdf: bytes, generate_appearance_streams: bool) -> bytes:
|
|
41
|
+
"""
|
|
42
|
+
Handles appearance streams and the /NeedAppearances flag for a PDF form.
|
|
43
|
+
|
|
44
|
+
This function prepares a PDF for output by:
|
|
45
|
+
1. Removing the XFA dictionary if present, as it can interfere with standard
|
|
46
|
+
AcroForm processing.
|
|
47
|
+
2. Setting the /NeedAppearances flag in the AcroForm dictionary, which asks
|
|
48
|
+
PDF viewers to generate appearance streams for form fields.
|
|
49
|
+
3. Optionally generating appearance streams explicitly using pikepdf if
|
|
50
|
+
`generate_appearance_streams` is True.
|
|
51
|
+
|
|
52
|
+
The result is cached using lru_cache for performance.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
pdf (bytes): The PDF file content as a bytes stream.
|
|
56
|
+
generate_appearance_streams (bool): Whether to explicitly generate appearance streams for all form fields.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
bytes: The modified PDF content as a bytes stream.
|
|
60
|
+
"""
|
|
61
|
+
reader = PdfReader(BytesIO(pdf))
|
|
62
|
+
writer = PdfWriter()
|
|
63
|
+
|
|
64
|
+
if AcroForm in reader.trailer[Root] and XFA in reader.trailer[Root][AcroForm]:
|
|
65
|
+
del reader.trailer[Root][AcroForm][XFA]
|
|
66
|
+
|
|
67
|
+
writer.append(reader)
|
|
68
|
+
writer.set_need_appearances_writer()
|
|
69
|
+
|
|
70
|
+
with BytesIO() as f:
|
|
71
|
+
writer.write(f)
|
|
72
|
+
f.seek(0)
|
|
73
|
+
result = f.read()
|
|
74
|
+
|
|
75
|
+
if generate_appearance_streams:
|
|
76
|
+
with Pdf.open(BytesIO(result)) as f, catch_warnings():
|
|
77
|
+
filterwarnings(
|
|
78
|
+
"ignore", message=".*/AcroForm.*"
|
|
79
|
+
) # handled by rebuild_acroform_fields
|
|
80
|
+
|
|
81
|
+
f.generate_appearance_streams()
|
|
82
|
+
with BytesIO() as r:
|
|
83
|
+
f.save(r, deterministic_id=True)
|
|
84
|
+
r.seek(0)
|
|
85
|
+
result = r.read()
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def preserve_pdf_properties(
|
|
91
|
+
pdf: bytes, title: str, script: str, metadata: dict
|
|
92
|
+
) -> bytes:
|
|
93
|
+
"""
|
|
94
|
+
Preserves and updates PDF properties such as metadata, title, and OpenAction scripts.
|
|
95
|
+
|
|
96
|
+
This function allows setting or updating the PDF's title and metadata, and
|
|
97
|
+
attaching a JavaScript script that executes when the PDF is opened. Metadata
|
|
98
|
+
is merged into the reader's current metadata when provided; the title and
|
|
99
|
+
OpenAction JavaScript are written only when non-empty values are supplied.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
pdf (bytes): The PDF file content as a bytes stream.
|
|
103
|
+
title (str): The title to be set in the PDF metadata.
|
|
104
|
+
script (str): JavaScript code to be executed when the PDF is opened.
|
|
105
|
+
metadata (dict): The original metadata to preserve.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
bytes: The modified PDF content as a bytes stream.
|
|
109
|
+
"""
|
|
110
|
+
reader = PdfReader(BytesIO(pdf))
|
|
111
|
+
writer = PdfWriter()
|
|
112
|
+
writer.append(reader)
|
|
113
|
+
|
|
114
|
+
if title or metadata:
|
|
115
|
+
_metadata = reader.metadata or {}
|
|
116
|
+
if metadata:
|
|
117
|
+
_metadata.update(metadata)
|
|
118
|
+
if title:
|
|
119
|
+
_metadata[NameObject(Title)] = TextStringObject(title)
|
|
120
|
+
|
|
121
|
+
writer.add_metadata(_metadata)
|
|
122
|
+
|
|
123
|
+
if script:
|
|
124
|
+
open_action = DictionaryObject()
|
|
125
|
+
open_action[NameObject(S)] = NameObject(JavaScript)
|
|
126
|
+
open_action[NameObject(JS)] = TextStringObject(script)
|
|
127
|
+
|
|
128
|
+
writer._root_object.update({NameObject(OpenAction): open_action}) # type: ignore # noqa: SLF001 # # pylint: disable=W0212
|
|
129
|
+
|
|
130
|
+
with BytesIO() as f:
|
|
131
|
+
writer.write(f)
|
|
132
|
+
f.seek(0)
|
|
133
|
+
return f.read()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rebuild_acroform_fields(
|
|
137
|
+
pdf: bytes, widget_keys: set, use_full_widget_name: bool
|
|
138
|
+
) -> bytes:
|
|
139
|
+
"""
|
|
140
|
+
Rebuilds the AcroForm `/Fields` array from matching page annotations.
|
|
141
|
+
|
|
142
|
+
The existing `/Fields` array is replaced, creating an AcroForm dictionary
|
|
143
|
+
when necessary. Each page annotation is resolved to a widget key, and only
|
|
144
|
+
annotations whose keys are present in `widget_keys` contribute their
|
|
145
|
+
top-level field object to the new array. Page annotation arrays are left
|
|
146
|
+
unchanged. When no matching page annotations are found, the original PDF
|
|
147
|
+
stream is returned unchanged to avoid an unnecessary rewrite.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
pdf (bytes): The PDF stream whose AcroForm fields should be rebuilt.
|
|
151
|
+
widget_keys (set): Widget keys to include in the rebuilt `/Fields` array.
|
|
152
|
+
use_full_widget_name (bool): Whether to resolve annotations using their
|
|
153
|
+
full widget names, including parent names.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
bytes: The PDF stream with a rebuilt AcroForm `/Fields` array, or the
|
|
157
|
+
original stream when there are no matching widgets to rebuild.
|
|
158
|
+
"""
|
|
159
|
+
if not widget_keys:
|
|
160
|
+
return pdf
|
|
161
|
+
|
|
162
|
+
writer = PdfWriter(BytesIO(pdf))
|
|
163
|
+
root = writer._root_object # type: ignore # noqa: SLF001 # # pylint: disable=W0212
|
|
164
|
+
|
|
165
|
+
fields = ArrayObject([])
|
|
166
|
+
seen_fields = set()
|
|
167
|
+
for page in writer.pages:
|
|
168
|
+
for annot in page.get(Annots, []):
|
|
169
|
+
key = get_widget_key(annot.get_object(), use_full_widget_name)
|
|
170
|
+
if key in widget_keys:
|
|
171
|
+
field_ref = _get_root_field_reference(writer, annot)
|
|
172
|
+
field_key = _field_reference_key(field_ref)
|
|
173
|
+
if field_key not in seen_fields:
|
|
174
|
+
fields.append(field_ref)
|
|
175
|
+
seen_fields.add(field_key)
|
|
176
|
+
|
|
177
|
+
if not seen_fields:
|
|
178
|
+
return pdf
|
|
179
|
+
|
|
180
|
+
if AcroForm not in root:
|
|
181
|
+
root[NameObject(AcroForm)] = DictionaryObject({})
|
|
182
|
+
root[AcroForm][NameObject(Fields)] = fields
|
|
183
|
+
|
|
184
|
+
with BytesIO() as f:
|
|
185
|
+
writer.write(f)
|
|
186
|
+
f.seek(0)
|
|
187
|
+
return f.read()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_root_field_reference(writer: PdfWriter, annot):
|
|
191
|
+
"""
|
|
192
|
+
Returns the top-level AcroForm field reference for an annotation.
|
|
193
|
+
|
|
194
|
+
Widget annotations can be leaf nodes under a parent field dictionary. The
|
|
195
|
+
catalog `/AcroForm/Fields` array must point at root fields rather than
|
|
196
|
+
child widgets, otherwise readers such as pypdf will not expose hierarchical
|
|
197
|
+
fields like radio groups via `get_fields`.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
writer (PdfWriter): The PDF writer that owns the annotation objects.
|
|
201
|
+
annot: The widget annotation reference or object.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
IndirectObject: The top-level AcroForm field reference.
|
|
205
|
+
"""
|
|
206
|
+
field = annot
|
|
207
|
+
field_object = field.get_object()
|
|
208
|
+
visited = set()
|
|
209
|
+
|
|
210
|
+
while Parent in field_object and id(field_object) not in visited:
|
|
211
|
+
visited.add(id(field_object))
|
|
212
|
+
field = field_object[Parent]
|
|
213
|
+
field_object = field.get_object()
|
|
214
|
+
|
|
215
|
+
return _ensure_indirect_reference(writer, field)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _ensure_indirect_reference(writer: PdfWriter, field):
|
|
219
|
+
"""
|
|
220
|
+
Returns an indirect reference for a field object.
|
|
221
|
+
|
|
222
|
+
pypdf expects entries in `/AcroForm/Fields` to have an indirect reference
|
|
223
|
+
when it builds field objects. Parent fields cloned from page annotations can
|
|
224
|
+
appear as direct dictionaries that still know their original indirect
|
|
225
|
+
reference, so prefer that before adding a new indirect object.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
writer (PdfWriter): The PDF writer that will own newly added objects.
|
|
229
|
+
field: The field reference or direct field object to normalize.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
IndirectObject: An indirect reference to the field object.
|
|
233
|
+
"""
|
|
234
|
+
field_object = field.get_object()
|
|
235
|
+
indirect_reference = getattr(field_object, "indirect_reference", None)
|
|
236
|
+
if indirect_reference is not None:
|
|
237
|
+
return indirect_reference
|
|
238
|
+
|
|
239
|
+
result = field
|
|
240
|
+
if not hasattr(field, "idnum"):
|
|
241
|
+
result = writer._add_object(field_object) # type: ignore[attr-defined] # noqa: SLF001 # pylint: disable=W0212
|
|
242
|
+
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _field_reference_key(field_ref) -> tuple:
|
|
247
|
+
"""
|
|
248
|
+
Returns a stable key for deduplicating root fields in one writer.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
field_ref: The field reference to identify.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
tuple: A stable key for the field reference.
|
|
255
|
+
"""
|
|
256
|
+
field_object = field_ref.get_object()
|
|
257
|
+
indirect_reference = getattr(field_object, "indirect_reference", field_ref)
|
|
258
|
+
idnum = getattr(indirect_reference, "idnum", None)
|
|
259
|
+
generation = getattr(indirect_reference, "generation", None)
|
|
260
|
+
|
|
261
|
+
result = (id(field_object),)
|
|
262
|
+
if idnum is not None:
|
|
263
|
+
result = idnum, generation
|
|
264
|
+
|
|
265
|
+
return result
|
|
@@ -21,7 +21,13 @@ from typing import Any, List
|
|
|
21
21
|
from pypdf import PdfReader, PdfWriter
|
|
22
22
|
from pypdf.generic import ArrayObject, DictionaryObject, NameObject
|
|
23
23
|
|
|
24
|
-
from .constants import
|
|
24
|
+
from .constants import (
|
|
25
|
+
SLASH,
|
|
26
|
+
UNIQUE_SUFFIX_LENGTH,
|
|
27
|
+
VERSION_IDENTIFIER_PREFIX,
|
|
28
|
+
VERSION_IDENTIFIERS,
|
|
29
|
+
Annots,
|
|
30
|
+
)
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
@lru_cache(maxsize=128)
|
|
@@ -329,3 +335,72 @@ def generate_unique_suffix() -> str:
|
|
|
329
335
|
for _ in range(UNIQUE_SUFFIX_LENGTH)
|
|
330
336
|
]
|
|
331
337
|
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def get_version(pdf: bytes) -> str | None:
|
|
341
|
+
"""
|
|
342
|
+
Extracts the PDF header version from a byte stream.
|
|
343
|
+
|
|
344
|
+
The stream is checked against the supported PDF header identifiers defined
|
|
345
|
+
in constants. The function inspects the original bytes directly so callers
|
|
346
|
+
can capture a document's version before handing it to tools that may rewrite
|
|
347
|
+
the header during output processing.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
pdf (bytes): The PDF stream to inspect.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
str | None: The PDF version string, or None when the stream does not
|
|
354
|
+
start with a known PDF version identifier.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
version_identifier_length = len(VERSION_IDENTIFIERS[0])
|
|
358
|
+
version_identifier = pdf[:version_identifier_length]
|
|
359
|
+
if version_identifier not in VERSION_IDENTIFIERS:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
return version_identifier[len(VERSION_IDENTIFIER_PREFIX) :].decode()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def set_version(pdf: bytes, old: str | None, new: str | None) -> bytes:
|
|
366
|
+
"""
|
|
367
|
+
Replaces the first PDF header version marker in a byte stream.
|
|
368
|
+
|
|
369
|
+
This helper only changes the literal header marker, such as `%PDF-1.7`; it
|
|
370
|
+
does not validate or rewrite the document for version-specific feature
|
|
371
|
+
compatibility. It is used after egress rewrites so output keeps the wrapper's
|
|
372
|
+
cached version instead of inheriting a writer-selected version.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
pdf (bytes): The PDF stream to update.
|
|
376
|
+
old (str | None): The currently present PDF version string. When
|
|
377
|
+
None, the header token after `%PDF-` is replaced if one exists.
|
|
378
|
+
new (str | None): The PDF version string to write into the header. When
|
|
379
|
+
None, the PDF stream is returned unchanged.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
bytes: The PDF stream with the first matching version marker replaced.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
if new is None or old == new:
|
|
386
|
+
return pdf
|
|
387
|
+
|
|
388
|
+
new_header = VERSION_IDENTIFIER_PREFIX + new.encode()
|
|
389
|
+
|
|
390
|
+
if old is None:
|
|
391
|
+
if not pdf.startswith(VERSION_IDENTIFIER_PREFIX):
|
|
392
|
+
return pdf
|
|
393
|
+
|
|
394
|
+
header_end = len(pdf)
|
|
395
|
+
for separator in (b"\r", b"\n"):
|
|
396
|
+
separator_index = pdf.find(separator, len(VERSION_IDENTIFIER_PREFIX))
|
|
397
|
+
if separator_index != -1:
|
|
398
|
+
header_end = min(header_end, separator_index)
|
|
399
|
+
|
|
400
|
+
return new_header + pdf[header_end:]
|
|
401
|
+
|
|
402
|
+
old_header = VERSION_IDENTIFIER_PREFIX + old.encode()
|
|
403
|
+
if pdf.startswith(old_header):
|
|
404
|
+
return new_header + pdf[len(old_header) :]
|
|
405
|
+
|
|
406
|
+
return pdf.replace(old_header, new_header, 1)
|
|
@@ -37,9 +37,12 @@ from .adapter import (
|
|
|
37
37
|
fp_or_f_obj_or_f_content_to_content,
|
|
38
38
|
fp_or_f_obj_or_stream_to_stream,
|
|
39
39
|
)
|
|
40
|
-
from .constants import VERSION_IDENTIFIER_PREFIX, VERSION_IDENTIFIERS
|
|
41
40
|
from .coordinate import generate_coordinate_grid
|
|
42
|
-
from .egress import
|
|
41
|
+
from .egress import (
|
|
42
|
+
appearance_streams_handler,
|
|
43
|
+
preserve_pdf_properties,
|
|
44
|
+
rebuild_acroform_fields,
|
|
45
|
+
)
|
|
43
46
|
from .filler import fill
|
|
44
47
|
from .font import (
|
|
45
48
|
get_all_available_fonts,
|
|
@@ -62,8 +65,10 @@ from .types import PdfArray
|
|
|
62
65
|
from .utils import (
|
|
63
66
|
generate_unique_suffix,
|
|
64
67
|
get_page_streams,
|
|
68
|
+
get_version,
|
|
65
69
|
merge_pdfs,
|
|
66
70
|
remove_all_widgets,
|
|
71
|
+
set_version,
|
|
67
72
|
)
|
|
68
73
|
from .watermark import (
|
|
69
74
|
copy_watermark_widgets,
|
|
@@ -144,12 +149,14 @@ class PdfWrapper:
|
|
|
144
149
|
self._stream = fp_or_f_obj_or_stream_to_stream(template)
|
|
145
150
|
self.widgets = {}
|
|
146
151
|
self.title: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
self._version = None
|
|
147
154
|
self._metadata = (
|
|
148
155
|
get_metadata(self._read()) if kwargs.get("preserve_metadata") else {}
|
|
149
156
|
)
|
|
150
157
|
self._on_open_javascript = None
|
|
151
158
|
self._available_fonts = {} # for setting /F1
|
|
152
|
-
self._available_fonts_loaded =
|
|
159
|
+
self._available_fonts_loaded = None # for lazy loading fonts
|
|
153
160
|
self._font_register_events = [] # for reregister
|
|
154
161
|
self._key_update_tracker = {} # for update key preserve old key attrs
|
|
155
162
|
self._keys_to_update = [] # for bulk update keys
|
|
@@ -221,8 +228,8 @@ class PdfWrapper:
|
|
|
221
228
|
It rebuilds the widget dictionary and invalidates the lazily loaded font cache.
|
|
222
229
|
"""
|
|
223
230
|
|
|
224
|
-
stream = self._read()
|
|
225
231
|
self._available_fonts_loaded = False
|
|
232
|
+
stream = self._read()
|
|
226
233
|
new_widgets = (
|
|
227
234
|
build_widgets(
|
|
228
235
|
stream,
|
|
@@ -363,15 +370,18 @@ class PdfWrapper:
|
|
|
363
370
|
"""
|
|
364
371
|
Returns the PDF version of the underlying PDF document.
|
|
365
372
|
|
|
373
|
+
The version is read from the PDF header lazily and cached so egress-only
|
|
374
|
+
rewrites can restore the wrapper's original version even if an underlying
|
|
375
|
+
PDF writer emits a different default header.
|
|
376
|
+
|
|
366
377
|
Returns:
|
|
367
378
|
str | None: The PDF version as a string, or None if the version cannot be determined.
|
|
368
379
|
"""
|
|
369
380
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return each.replace(VERSION_IDENTIFIER_PREFIX, b"").decode()
|
|
381
|
+
if self._version is None:
|
|
382
|
+
self._version = get_version(self._read())
|
|
373
383
|
|
|
374
|
-
return
|
|
384
|
+
return self._version
|
|
375
385
|
|
|
376
386
|
@property
|
|
377
387
|
def fonts(self) -> list:
|
|
@@ -452,6 +462,11 @@ class PdfWrapper:
|
|
|
452
462
|
generating appearance streams.
|
|
453
463
|
3. If `preserve_metadata`, title, or on-open JavaScript are set, it preserves
|
|
454
464
|
or updates the corresponding PDF properties accordingly.
|
|
465
|
+
4. Rebuilds the AcroForm `/Fields` array from page annotations for
|
|
466
|
+
widgets known to this wrapper, leaving the stream unchanged when no
|
|
467
|
+
matching widget annotations are found.
|
|
468
|
+
5. Restores the wrapper's cached PDF header version after egress
|
|
469
|
+
processing, since PDF writers may emit their own default version.
|
|
455
470
|
The wrapper's stored stream is not replaced by these final egress-only changes.
|
|
456
471
|
|
|
457
472
|
Returns:
|
|
@@ -464,8 +479,15 @@ class PdfWrapper:
|
|
|
464
479
|
result, getattr(self, "generate_appearance_streams")
|
|
465
480
|
) # cached
|
|
466
481
|
|
|
467
|
-
if
|
|
468
|
-
|
|
482
|
+
if (
|
|
483
|
+
any(
|
|
484
|
+
[
|
|
485
|
+
getattr(self, "preserve_metadata"),
|
|
486
|
+
self.title,
|
|
487
|
+
self.on_open_javascript,
|
|
488
|
+
]
|
|
489
|
+
)
|
|
490
|
+
and result
|
|
469
491
|
):
|
|
470
492
|
result = preserve_pdf_properties(
|
|
471
493
|
result,
|
|
@@ -474,6 +496,12 @@ class PdfWrapper:
|
|
|
474
496
|
self._metadata if getattr(self, "preserve_metadata") else None,
|
|
475
497
|
)
|
|
476
498
|
|
|
499
|
+
if result:
|
|
500
|
+
result = rebuild_acroform_fields(
|
|
501
|
+
result, set(self.widgets.keys()), getattr(self, "use_full_widget_name")
|
|
502
|
+
)
|
|
503
|
+
if self.version:
|
|
504
|
+
result = set_version(result, get_version(result), self.version)
|
|
477
505
|
return result
|
|
478
506
|
|
|
479
507
|
def _read(self) -> bytes:
|
|
@@ -546,9 +574,9 @@ class PdfWrapper:
|
|
|
546
574
|
"""
|
|
547
575
|
Changes the PDF version of the underlying document.
|
|
548
576
|
|
|
549
|
-
The method replaces the first PDF header version marker in the current stream
|
|
550
|
-
|
|
551
|
-
compatibility.
|
|
577
|
+
The method replaces the first PDF header version marker in the current stream
|
|
578
|
+
and updates the cached version used by later egress processing. It does not
|
|
579
|
+
otherwise validate or rewrite the document for version-specific compatibility.
|
|
552
580
|
|
|
553
581
|
Args:
|
|
554
582
|
version (str): The new PDF version string (e.g., "1.7").
|
|
@@ -557,11 +585,11 @@ class PdfWrapper:
|
|
|
557
585
|
PdfWrapper: The `PdfWrapper` object, allowing for method chaining.
|
|
558
586
|
"""
|
|
559
587
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
588
|
+
current_stream = self._read()
|
|
589
|
+
updated_stream = set_version(current_stream, self.version, version)
|
|
590
|
+
if updated_stream != current_stream:
|
|
591
|
+
self._stream = updated_stream
|
|
592
|
+
self._version = version
|
|
565
593
|
|
|
566
594
|
return self
|
|
567
595
|
|
|
@@ -10,7 +10,7 @@ from PyPDFForm import BlankPage, Fields, PdfWrapper
|
|
|
10
10
|
from PyPDFForm.lib.constants import Annots, Subtype, Widget
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
@pytest.mark.
|
|
13
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
14
14
|
def test_bulk_create_fields_stress_max(pdf_samples, request):
|
|
15
15
|
expected_path = os.path.join(
|
|
16
16
|
pdf_samples, "bulk_create_fields", "test_bulk_create_fields_stress_max.pdf"
|
|
@@ -86,7 +86,7 @@ def test_bulk_create_fields_stress_max(pdf_samples, request):
|
|
|
86
86
|
assert obj.read() == expected
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
@pytest.mark.
|
|
89
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
90
90
|
def test_bulk_create_fields_stress_max_mixed(pdf_samples, request):
|
|
91
91
|
expected_path = os.path.join(
|
|
92
92
|
pdf_samples,
|
|
@@ -7,7 +7,7 @@ import pytest
|
|
|
7
7
|
from PyPDFForm import Fields, PdfWrapper
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
@pytest.mark.
|
|
10
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
11
11
|
def test_create_checkbox_complex_fill(template_stream, pdf_samples, request):
|
|
12
12
|
expected_path = os.path.join(
|
|
13
13
|
pdf_samples, "widget", "test_create_checkbox_complex_fill.pdf"
|
|
@@ -42,7 +42,7 @@ def test_create_checkbox_complex_fill(template_stream, pdf_samples, request):
|
|
|
42
42
|
assert obj.read() == expected
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
@pytest.mark.
|
|
45
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
46
46
|
def test_create_checkbox_check_fill(template_stream, pdf_samples, request):
|
|
47
47
|
expected_path = os.path.join(
|
|
48
48
|
pdf_samples, "widget", "test_create_checkbox_check_fill.pdf"
|
|
@@ -70,7 +70,7 @@ def test_create_checkbox_check_fill(template_stream, pdf_samples, request):
|
|
|
70
70
|
assert obj.read() == expected
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
@pytest.mark.
|
|
73
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
74
74
|
def test_create_checkbox_circle_fill(template_stream, pdf_samples, request):
|
|
75
75
|
expected_path = os.path.join(
|
|
76
76
|
pdf_samples, "widget", "test_create_checkbox_circle_fill.pdf"
|
|
@@ -98,7 +98,7 @@ def test_create_checkbox_circle_fill(template_stream, pdf_samples, request):
|
|
|
98
98
|
assert obj.read() == expected
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
@pytest.mark.
|
|
101
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
102
102
|
def test_create_checkbox_cross_fill(template_stream, pdf_samples, request):
|
|
103
103
|
expected_path = os.path.join(
|
|
104
104
|
pdf_samples, "widget", "test_create_checkbox_cross_fill.pdf"
|
|
@@ -126,7 +126,7 @@ def test_create_checkbox_cross_fill(template_stream, pdf_samples, request):
|
|
|
126
126
|
assert obj.read() == expected
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
@pytest.mark.
|
|
129
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
130
130
|
def test_create_text_alpha_bg_color(template_stream, pdf_samples, request):
|
|
131
131
|
expected_path = os.path.join(
|
|
132
132
|
pdf_samples, "widget", "test_create_text_alpha_bg_color.pdf"
|
|
@@ -154,7 +154,7 @@ def test_create_text_alpha_bg_color(template_stream, pdf_samples, request):
|
|
|
154
154
|
assert obj.read() == expected
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
@pytest.mark.
|
|
157
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
158
158
|
def test_create_text_align_center(template_stream, pdf_samples, request):
|
|
159
159
|
expected_path = os.path.join(
|
|
160
160
|
pdf_samples, "widget", "test_create_text_align_center.pdf"
|
|
@@ -182,7 +182,7 @@ def test_create_text_align_center(template_stream, pdf_samples, request):
|
|
|
182
182
|
assert obj.read() == expected
|
|
183
183
|
|
|
184
184
|
|
|
185
|
-
@pytest.mark.
|
|
185
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
186
186
|
def test_create_text_multiline(template_stream, pdf_samples, request):
|
|
187
187
|
expected_path = os.path.join(
|
|
188
188
|
pdf_samples, "widget", "test_create_text_align_multiline.pdf"
|
|
@@ -210,7 +210,7 @@ def test_create_text_multiline(template_stream, pdf_samples, request):
|
|
|
210
210
|
assert obj.read() == expected
|
|
211
211
|
|
|
212
212
|
|
|
213
|
-
@pytest.mark.
|
|
213
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
214
214
|
def test_create_text_complex_filled(
|
|
215
215
|
template_stream, pdf_samples, sample_font_stream, request
|
|
216
216
|
):
|
|
@@ -252,7 +252,7 @@ def test_create_text_complex_filled(
|
|
|
252
252
|
assert obj.read() == expected
|
|
253
253
|
|
|
254
254
|
|
|
255
|
-
@pytest.mark.
|
|
255
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
256
256
|
def test_create_text_comb(template_stream, pdf_samples, request):
|
|
257
257
|
expected_path = os.path.join(pdf_samples, "widget", "test_create_text_comb.pdf")
|
|
258
258
|
with open(expected_path, "rb+") as f:
|
|
@@ -278,7 +278,7 @@ def test_create_text_comb(template_stream, pdf_samples, request):
|
|
|
278
278
|
assert obj.read() == expected
|
|
279
279
|
|
|
280
280
|
|
|
281
|
-
@pytest.mark.
|
|
281
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
282
282
|
def test_create_checkbox_persist_old_widgets_fill(
|
|
283
283
|
template_stream, pdf_samples, request
|
|
284
284
|
):
|
|
@@ -310,7 +310,7 @@ def test_create_checkbox_persist_old_widgets_fill(
|
|
|
310
310
|
assert obj.read() == expected
|
|
311
311
|
|
|
312
312
|
|
|
313
|
-
@pytest.mark.
|
|
313
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
314
314
|
def test_create_widget_sejda_fill_flatten_before(sejda_template, pdf_samples, request):
|
|
315
315
|
expected_path = os.path.join(
|
|
316
316
|
pdf_samples, "widget", "test_create_widget_sejda_fill_flatten_before.pdf"
|
|
@@ -344,7 +344,7 @@ def test_create_widget_sejda_fill_flatten_before(sejda_template, pdf_samples, re
|
|
|
344
344
|
assert obj.read() == expected
|
|
345
345
|
|
|
346
346
|
|
|
347
|
-
@pytest.mark.
|
|
347
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
348
348
|
def test_create_widget_sejda_fill_flatten_after(sejda_template, pdf_samples, request):
|
|
349
349
|
expected_path = os.path.join(
|
|
350
350
|
pdf_samples, "widget", "test_create_widget_sejda_fill_flatten_after.pdf"
|
|
@@ -422,7 +422,7 @@ def test_fill_cmyk_color(pdf_samples, request):
|
|
|
422
422
|
assert obj.read() == expected
|
|
423
423
|
|
|
424
424
|
|
|
425
|
-
@pytest.mark.
|
|
425
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
426
426
|
def test_create_radio_complex(template_stream, pdf_samples, request):
|
|
427
427
|
expected_path = os.path.join(pdf_samples, "widget", "test_create_radio_complex.pdf")
|
|
428
428
|
with open(expected_path, "rb+") as f:
|
|
@@ -454,7 +454,7 @@ def test_create_radio_complex(template_stream, pdf_samples, request):
|
|
|
454
454
|
assert obj.read() == expected
|
|
455
455
|
|
|
456
456
|
|
|
457
|
-
@pytest.mark.
|
|
457
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
458
458
|
def test_create_required_fields(pdf_samples, request):
|
|
459
459
|
expected_path = os.path.join(
|
|
460
460
|
pdf_samples, "widget", "test_create_required_fields.pdf"
|
|
@@ -505,7 +505,7 @@ def test_create_required_fields(pdf_samples, request):
|
|
|
505
505
|
assert obj.read() == expected
|
|
506
506
|
|
|
507
507
|
|
|
508
|
-
@pytest.mark.
|
|
508
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
509
509
|
def test_create_not_required_fields(pdf_samples, request):
|
|
510
510
|
expected_path = os.path.join(
|
|
511
511
|
pdf_samples, "widget", "test_create_not_required_fields.pdf"
|
|
@@ -556,7 +556,7 @@ def test_create_not_required_fields(pdf_samples, request):
|
|
|
556
556
|
assert obj.read() == expected
|
|
557
557
|
|
|
558
558
|
|
|
559
|
-
@pytest.mark.
|
|
559
|
+
@pytest.mark.requires_zlib_over_zlib_ng
|
|
560
560
|
def test_create_fields_with_tooltips(pdf_samples, request):
|
|
561
561
|
expected_path = os.path.join(
|
|
562
562
|
pdf_samples, "widget", "test_create_fields_with_tooltips.pdf"
|