PyPDFForm 6.0.1__tar.gz → 6.0.2__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-6.0.1 → pypdfform-6.0.2}/PKG-INFO +3 -3
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/__init__.py +1 -1
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/constants.py +1 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/egress.py +73 -17
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/filler.py +52 -8
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/font.py +20 -8
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/patterns.py +10 -9
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/wrapper.py +19 -29
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/PKG-INFO +3 -3
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/requires.txt +2 -2
- {pypdfform-6.0.1 → pypdfform-6.0.2}/pyproject.toml +2 -2
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_font_related.py +97 -2
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_functional.py +330 -120
- pypdfform-6.0.2/tests/test_signature.py +447 -0
- pypdfform-6.0.1/tests/test_signature.py +0 -136
- {pypdfform-6.0.1 → pypdfform-6.0.2}/LICENSE +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/common.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/create.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/entry.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/inspect.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/remove.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/root.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/create.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/update.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/update.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/adapter.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/base.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/link.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/stamp.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text_markup.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/assets/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/assets/blank.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/coordinate.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/deprecation.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/hooks.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/image.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/base.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/checkbox.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/dropdown.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/image.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/radio.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/signature.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/text.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/circle.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/ellipse.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/image.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/line.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/rect.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/text.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/template.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/types.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/utils.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/watermark.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/__init__.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/base.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/checkbox.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/dropdown.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/image.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/radio.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/signature.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/text.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/SOURCES.txt +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/dependency_links.txt +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/entry_points.txt +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/top_level.txt +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/README.md +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/setup.cfg +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_bulk_create_fields.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_create_widget.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_draw_elements.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_dropdown.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_extract_middleware_attributes.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_fill_max_length_text_field.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_generate_appearance_streams.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_js.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_need_appearances.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_paragraph.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_use_full_widget_name.py +0 -0
- {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_widget_attr_trigger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyPDFForm
|
|
3
|
-
Version: 6.0.
|
|
3
|
+
Version: 6.0.2
|
|
4
4
|
Summary: The Python library & CLI for PDF forms.
|
|
5
5
|
Author: Jinge Li
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,7 +27,7 @@ License-File: LICENSE
|
|
|
27
27
|
Requires-Dist: cryptography<51.0.0,>=50.0.0
|
|
28
28
|
Requires-Dist: fonttools<5.0.0,>=4.63.0
|
|
29
29
|
Requires-Dist: pikepdf<11.0.0,>=10.7.2
|
|
30
|
-
Requires-Dist: pillow<
|
|
30
|
+
Requires-Dist: pillow<14.0.0,>=12.2.0
|
|
31
31
|
Requires-Dist: pypdf<7.0.0,>=6.12.2
|
|
32
32
|
Requires-Dist: reportlab<6.0.0,>=4.5.1
|
|
33
33
|
Provides-Extra: cli
|
|
@@ -39,7 +39,7 @@ Requires-Dist: coverage<8.0.0,>=7.14.1; extra == "dev"
|
|
|
39
39
|
Requires-Dist: mike<3.0.0,>=2.2.0; extra == "dev"
|
|
40
40
|
Requires-Dist: mkdocs<2.0.0,>=1.6.1; extra == "dev"
|
|
41
41
|
Requires-Dist: mkdocs-material<10.0.0,>=9.7.6; extra == "dev"
|
|
42
|
-
Requires-Dist: pudb<
|
|
42
|
+
Requires-Dist: pudb<2027.0.0,>=2025.1.5; extra == "dev"
|
|
43
43
|
Requires-Dist: pylint<5.0.0,>=4.0.5; extra == "dev"
|
|
44
44
|
Requires-Dist: pyright<2.0.0,>=1.1.409; extra == "dev"
|
|
45
45
|
Requires-Dist: pytest<10.0.0,>=9.0.3; extra == "dev"
|
|
@@ -16,7 +16,7 @@ PyPDFForm aims to make PDF form automation straightforward whether it is used
|
|
|
16
16
|
from Python code or from the command line.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
__version__ = "6.0.
|
|
19
|
+
__version__ = "6.0.2"
|
|
20
20
|
|
|
21
21
|
from .lib.annotations import Annotations
|
|
22
22
|
from .lib.assets.blank import BlankPage
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Prepare PDF output and publish serialized PDFs to filesystem paths.
|
|
4
|
+
|
|
5
|
+
Preparation helpers manage appearance streams and the /NeedAppearances flag,
|
|
6
|
+
and rebuild the AcroForm `/Fields` array from page widget annotations before
|
|
7
|
+
`PdfWrapper.read()` returns the final bytes.
|
|
8
|
+
|
|
9
|
+
For path output, `write_path()` writes serialized bytes to a sibling temporary
|
|
10
|
+
file and closes it before atomic replacement. It follows symlinks, preserves
|
|
11
|
+
existing permission bits, and cleans up failed temporary writes.
|
|
11
12
|
"""
|
|
12
13
|
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
from contextlib import suppress
|
|
13
17
|
from functools import lru_cache
|
|
14
18
|
from io import BytesIO
|
|
19
|
+
from tempfile import NamedTemporaryFile
|
|
15
20
|
from warnings import catch_warnings, filterwarnings
|
|
16
21
|
|
|
17
22
|
from pikepdf import Pdf
|
|
@@ -24,10 +29,57 @@ from .constants import (
|
|
|
24
29
|
Annots,
|
|
25
30
|
Fields,
|
|
26
31
|
Parent,
|
|
32
|
+
Subtype,
|
|
33
|
+
Widget,
|
|
27
34
|
)
|
|
28
35
|
from .template import get_widget_key
|
|
29
36
|
|
|
30
37
|
|
|
38
|
+
def write_path(dest: str | bytes | os.PathLike, content: bytes) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Write serialized content to a sibling temporary file and replace its target.
|
|
41
|
+
|
|
42
|
+
Symlinks are followed. Existing permission bits are copied before replacement;
|
|
43
|
+
new files retain the temporary file's private permissions. Other file metadata
|
|
44
|
+
and hard-link relationships are not preserved. Failures before replacement
|
|
45
|
+
leave the destination's bytes unchanged and remove the temporary file.
|
|
46
|
+
"""
|
|
47
|
+
path = os.path.realpath(os.fsdecode(dest))
|
|
48
|
+
mode = None
|
|
49
|
+
try:
|
|
50
|
+
destination_stat = os.stat(path)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
pass
|
|
53
|
+
else:
|
|
54
|
+
if not stat.S_ISREG(destination_stat.st_mode):
|
|
55
|
+
message = "The output destination must be a regular file."
|
|
56
|
+
raise OSError(message)
|
|
57
|
+
mode = stat.S_IMODE(destination_stat.st_mode)
|
|
58
|
+
|
|
59
|
+
temporary_path = None
|
|
60
|
+
try:
|
|
61
|
+
with NamedTemporaryFile(
|
|
62
|
+
mode="wb", prefix=".pypdfform-", dir=os.path.dirname(path), delete=False
|
|
63
|
+
) as output:
|
|
64
|
+
temporary_path = output.name
|
|
65
|
+
if output.write(content) != len(content):
|
|
66
|
+
message = "The temporary PDF write was incomplete."
|
|
67
|
+
raise OSError(message)
|
|
68
|
+
|
|
69
|
+
if mode is not None:
|
|
70
|
+
os.chmod(temporary_path, mode)
|
|
71
|
+
os.replace(temporary_path, path)
|
|
72
|
+
finally:
|
|
73
|
+
if temporary_path is not None:
|
|
74
|
+
with suppress(FileNotFoundError):
|
|
75
|
+
try:
|
|
76
|
+
os.unlink(temporary_path)
|
|
77
|
+
except PermissionError:
|
|
78
|
+
# Windows cannot unlink a temporary file made read-only above.
|
|
79
|
+
os.chmod(temporary_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
os.unlink(temporary_path)
|
|
81
|
+
|
|
82
|
+
|
|
31
83
|
@lru_cache(maxsize=8)
|
|
32
84
|
def appearance_streams_handler(pdf: bytes, generate_appearance_streams: bool) -> bytes:
|
|
33
85
|
"""
|
|
@@ -85,8 +137,8 @@ def rebuild_acroform_fields(
|
|
|
85
137
|
Rebuilds the AcroForm `/Fields` array from matching page annotations.
|
|
86
138
|
|
|
87
139
|
The existing `/Fields` array is replaced, creating an AcroForm dictionary
|
|
88
|
-
when necessary.
|
|
89
|
-
|
|
140
|
+
when necessary. Only widget annotations are resolved to a widget key, and
|
|
141
|
+
those whose keys are present in `widget_keys` contribute their
|
|
90
142
|
top-level field object to the new array. Page annotation arrays are left
|
|
91
143
|
unchanged. When no matching page annotations are found, the original PDF
|
|
92
144
|
stream is returned unchanged to avoid an unnecessary rewrite.
|
|
@@ -111,13 +163,17 @@ def rebuild_acroform_fields(
|
|
|
111
163
|
seen_fields = set()
|
|
112
164
|
for page in writer.pages:
|
|
113
165
|
for annot in page.get(Annots, []):
|
|
114
|
-
|
|
115
|
-
if
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
166
|
+
annotation = annot.get_object()
|
|
167
|
+
if (
|
|
168
|
+
annotation.get(Subtype) != Widget
|
|
169
|
+
or get_widget_key(annotation, use_full_widget_name) not in widget_keys
|
|
170
|
+
):
|
|
171
|
+
continue
|
|
172
|
+
field_ref = _get_root_field_reference(writer, annot)
|
|
173
|
+
field_key = _field_reference_key(field_ref)
|
|
174
|
+
if field_key not in seen_fields:
|
|
175
|
+
fields.append(field_ref)
|
|
176
|
+
seen_fields.add(field_key)
|
|
121
177
|
|
|
122
178
|
if not seen_fields:
|
|
123
179
|
return pdf
|
|
@@ -12,9 +12,9 @@ from io import BytesIO
|
|
|
12
12
|
from typing import Dict, cast
|
|
13
13
|
|
|
14
14
|
from pypdf import PdfWriter
|
|
15
|
-
from pypdf.generic import DictionaryObject
|
|
15
|
+
from pypdf.generic import ArrayObject, DictionaryObject
|
|
16
16
|
|
|
17
|
-
from .constants import
|
|
17
|
+
from .constants import AcroForm, Fields, Kids, Parent, Subtype, Widget
|
|
18
18
|
from .hooks import update_field_readonly
|
|
19
19
|
from .image import get_draw_image_resolutions, get_image_dimensions
|
|
20
20
|
from .middleware import WIDGET_TYPES
|
|
@@ -90,7 +90,8 @@ def update_widget(
|
|
|
90
90
|
|
|
91
91
|
This function updates the value of a single PDF form widget based on its type. It sets
|
|
92
92
|
the read-only flag first when requested, skips value updates when the
|
|
93
|
-
middleware value is ``None``, tracks radio option indices within each group,
|
|
93
|
+
middleware value is ``None``, tracks radio option indices within each group,
|
|
94
|
+
updates the entire radio group when its selected option is reached, and
|
|
94
95
|
prepares images or signatures for later drawing instead of writing image data directly
|
|
95
96
|
into the annotation.
|
|
96
97
|
|
|
@@ -156,6 +157,36 @@ def handle_image_drawing(
|
|
|
156
157
|
return merge_watermarks_with_pdf(result, create_watermarks_and_draw(result, images))
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
def _detach_consumed_widget(writer: PdfWriter, annot: DictionaryObject) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Detaches a consumed widget and empty ancestor fields from the AcroForm tree.
|
|
163
|
+
|
|
164
|
+
This function removes the widget from its parent's child array or the
|
|
165
|
+
AcroForm's root field array. It removes ancestors that become empty, stopping
|
|
166
|
+
at the first parent with remaining children. Resolved objects are compared
|
|
167
|
+
by identity so unrelated fields with the same name retain their references.
|
|
168
|
+
The caller is responsible for removing the widget from page annotations.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
writer (PdfWriter): The PDF writer that owns the widget and field tree.
|
|
172
|
+
annot (DictionaryObject): The consumed image or signature widget to detach.
|
|
173
|
+
"""
|
|
174
|
+
field = annot
|
|
175
|
+
while Parent in field:
|
|
176
|
+
parent = cast(DictionaryObject, field[Parent])
|
|
177
|
+
children = cast(ArrayObject, parent.get(Kids, ArrayObject()).get_object())
|
|
178
|
+
children[:] = [child for child in children if child.get_object() is not field]
|
|
179
|
+
if children:
|
|
180
|
+
return
|
|
181
|
+
field = parent
|
|
182
|
+
|
|
183
|
+
acroform = writer.root_object.get(AcroForm, DictionaryObject()).get_object()
|
|
184
|
+
fields = cast(ArrayObject, acroform.get(Fields, ArrayObject()).get_object())
|
|
185
|
+
fields[:] = [
|
|
186
|
+
reference for reference in fields if reference.get_object() is not field
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
159
190
|
def fill(
|
|
160
191
|
template: bytes,
|
|
161
192
|
widgets: Dict[str, WIDGET_TYPES],
|
|
@@ -169,8 +200,9 @@ def fill(
|
|
|
169
200
|
widgets on each page of the PDF and updates their values based on the provided `widgets`
|
|
170
201
|
dictionary. The function supports text fields, checkboxes, radio buttons, dropdowns,
|
|
171
202
|
images, and signatures. It can set fields read-only during filling, and when images or
|
|
172
|
-
signatures are present it
|
|
173
|
-
|
|
203
|
+
signatures are present it removes only the widgets whose images were prepared,
|
|
204
|
+
including their field-tree entries, and returns a second stream with the image
|
|
205
|
+
watermarks applied. All other annotations retain their existing references.
|
|
174
206
|
|
|
175
207
|
Args:
|
|
176
208
|
template (bytes): The PDF template as bytes.
|
|
@@ -196,15 +228,21 @@ def fill(
|
|
|
196
228
|
|
|
197
229
|
for page_num, page in enumerate(out.pages):
|
|
198
230
|
images_to_draw[page_num + 1] = []
|
|
199
|
-
|
|
200
|
-
|
|
231
|
+
annotations = page.annotations
|
|
232
|
+
if annotations is None:
|
|
233
|
+
continue
|
|
234
|
+
# Keep a snapshot because consumed widgets are removed during iteration.
|
|
235
|
+
for reference in annotations.copy():
|
|
236
|
+
annot = cast(DictionaryObject, reference.get_object())
|
|
237
|
+
if annot.get(Subtype) != Widget:
|
|
238
|
+
continue
|
|
201
239
|
key = get_widget_key(annot.get_object(), use_full_widget_name)
|
|
202
240
|
|
|
203
241
|
widget = widgets.get(key)
|
|
204
242
|
if widget is None:
|
|
205
243
|
continue
|
|
206
244
|
|
|
207
|
-
|
|
245
|
+
consumed = update_widget(
|
|
208
246
|
annot,
|
|
209
247
|
widget,
|
|
210
248
|
key,
|
|
@@ -213,6 +251,12 @@ def fill(
|
|
|
213
251
|
need_appearances,
|
|
214
252
|
readonly,
|
|
215
253
|
)
|
|
254
|
+
if consumed:
|
|
255
|
+
any_image_to_draw = True
|
|
256
|
+
annotations[:] = [
|
|
257
|
+
item for item in annotations if item.get_object() is not annot
|
|
258
|
+
]
|
|
259
|
+
_detach_consumed_widget(out, annot)
|
|
216
260
|
|
|
217
261
|
with BytesIO() as f:
|
|
218
262
|
out.write(f)
|
|
@@ -191,7 +191,14 @@ def temporary_font_registration(
|
|
|
191
191
|
from the original font names to the unique names.
|
|
192
192
|
|
|
193
193
|
The fonts are inserted into ReportLab's global font registry for the duration
|
|
194
|
-
of the context manager
|
|
194
|
+
of the context manager. Every inserted entry is tracked independently of the
|
|
195
|
+
alias mapping and removed on exit, including setup and drawing failures.
|
|
196
|
+
Repeated aliases resolve to the last registration. Cleanup only removes
|
|
197
|
+
entries still referencing the font objects inserted by this context.
|
|
198
|
+
|
|
199
|
+
Sequential and nested contexts are supported. Callers must serialize threaded
|
|
200
|
+
PyPDFForm and direct ReportLab operations in the same process for the entire
|
|
201
|
+
operation; this context manager does not synchronize the global registry.
|
|
195
202
|
|
|
196
203
|
Args:
|
|
197
204
|
fonts (list[tuple[str, bytes]]): A list of tuples, each containing a font name and its TTF stream.
|
|
@@ -200,16 +207,21 @@ def temporary_font_registration(
|
|
|
200
207
|
dict: A mapping of the original font names to the temporary unique names used by ReportLab.
|
|
201
208
|
"""
|
|
202
209
|
font_mapping = {}
|
|
203
|
-
|
|
204
|
-
rl_name = uuid4().hex
|
|
205
|
-
font_mapping[font_name] = rl_name
|
|
206
|
-
_fonts[rl_name] = TTFont(rl_name, BytesIO(ttf_stream))
|
|
207
|
-
|
|
210
|
+
registered_fonts = {}
|
|
208
211
|
try:
|
|
212
|
+
for font_name, ttf_stream in fonts:
|
|
213
|
+
rl_name = uuid4().hex
|
|
214
|
+
while rl_name in _fonts:
|
|
215
|
+
rl_name = uuid4().hex
|
|
216
|
+
font = TTFont(rl_name, BytesIO(ttf_stream))
|
|
217
|
+
registered_fonts[rl_name] = font
|
|
218
|
+
_fonts[rl_name] = font
|
|
219
|
+
font_mapping[font_name] = rl_name
|
|
220
|
+
|
|
209
221
|
yield font_mapping
|
|
210
222
|
finally:
|
|
211
|
-
for rl_name in
|
|
212
|
-
if rl_name
|
|
223
|
+
for rl_name, font in registered_fonts.items():
|
|
224
|
+
if _fonts.get(rl_name) is font:
|
|
213
225
|
del _fonts[rl_name]
|
|
214
226
|
|
|
215
227
|
|
|
@@ -34,6 +34,7 @@ from .constants import (
|
|
|
34
34
|
F,
|
|
35
35
|
Ff,
|
|
36
36
|
I,
|
|
37
|
+
Kids,
|
|
37
38
|
MaxLen,
|
|
38
39
|
N,
|
|
39
40
|
Off,
|
|
@@ -260,22 +261,22 @@ def get_checkbox_value(annot: DictionaryObject) -> bool | None:
|
|
|
260
261
|
|
|
261
262
|
def update_radio_value(annot: DictionaryObject) -> None:
|
|
262
263
|
"""
|
|
263
|
-
|
|
264
|
+
Selects a radio button and clears every other widget in its group.
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
The selected widget's non-Off normal appearance supplies the parent value
|
|
267
|
+
(V) and selected appearance state (AS). All other children, including those
|
|
268
|
+
on other pages, receive /Off. The parent's Opt export mapping is preserved.
|
|
268
269
|
|
|
269
270
|
Args:
|
|
270
271
|
annot (DictionaryObject): The radio button annotation dictionary.
|
|
271
272
|
"""
|
|
272
|
-
if Opt in annot[Parent]:
|
|
273
|
-
del annot[Parent][Opt]
|
|
274
|
-
|
|
275
273
|
for each in annot[AP][N]:
|
|
276
274
|
if str(each) != Off:
|
|
277
|
-
annot[
|
|
278
|
-
|
|
275
|
+
parent = annot[Parent]
|
|
276
|
+
parent[NameObject(V)] = NameObject(each)
|
|
277
|
+
for reference in parent[Kids]:
|
|
278
|
+
child = reference.get_object()
|
|
279
|
+
child[NameObject(AS)] = NameObject(each if child is annot else Off)
|
|
279
280
|
break
|
|
280
281
|
|
|
281
282
|
|
|
@@ -40,6 +40,7 @@ from .coordinate import generate_coordinate_grid
|
|
|
40
40
|
from .egress import (
|
|
41
41
|
appearance_streams_handler,
|
|
42
42
|
rebuild_acroform_fields,
|
|
43
|
+
write_path,
|
|
43
44
|
)
|
|
44
45
|
from .filler import fill
|
|
45
46
|
from .font import (
|
|
@@ -50,7 +51,6 @@ from .font import (
|
|
|
50
51
|
)
|
|
51
52
|
from .hooks import trigger_widget_hooks
|
|
52
53
|
from .middleware.dropdown import Dropdown
|
|
53
|
-
from .middleware.signature import Signature
|
|
54
54
|
from .middleware.text import Text
|
|
55
55
|
from .template import (
|
|
56
56
|
build_widgets,
|
|
@@ -574,26 +574,26 @@ class PdfWrapper:
|
|
|
574
574
|
|
|
575
575
|
return self._stream
|
|
576
576
|
|
|
577
|
-
def write(self, dest: str | BinaryIO) -> PdfWrapper:
|
|
577
|
+
def write(self, dest: str | bytes | PathLike | BinaryIO) -> PdfWrapper:
|
|
578
578
|
"""
|
|
579
|
-
|
|
579
|
+
Serialize the PDF, then write it to a path or an open binary stream.
|
|
580
580
|
|
|
581
|
-
|
|
582
|
-
|
|
581
|
+
Paths use atomic replacement after a sibling temporary file closes; failures preserve existing bytes.
|
|
582
|
+
Symlinks are followed and permission bits preserved; new files are private (0600 on POSIX).
|
|
583
|
+
Other metadata and hard links are not preserved. Stream write failures can leave partial output.
|
|
583
584
|
|
|
584
585
|
Args:
|
|
585
|
-
dest (str | BinaryIO):
|
|
586
|
-
Can be a file path (str) or a file-like object (BinaryIO).
|
|
586
|
+
dest (str | bytes | PathLike | BinaryIO): A regular file path or writable binary stream.
|
|
587
587
|
|
|
588
588
|
Returns:
|
|
589
589
|
PdfWrapper: The `PdfWrapper` object, allowing for method chaining.
|
|
590
590
|
"""
|
|
591
591
|
|
|
592
|
+
content = self.read()
|
|
592
593
|
if isinstance(dest, (str, bytes, PathLike)):
|
|
593
|
-
|
|
594
|
-
f.write(self.read())
|
|
594
|
+
write_path(dest, content)
|
|
595
595
|
else:
|
|
596
|
-
dest.write(
|
|
596
|
+
dest.write(content)
|
|
597
597
|
|
|
598
598
|
return self
|
|
599
599
|
|
|
@@ -664,10 +664,11 @@ class PdfWrapper:
|
|
|
664
664
|
Fills the PDF form with data from a dictionary.
|
|
665
665
|
|
|
666
666
|
Only keys that already exist in `self.widgets` are applied. Filling delegates
|
|
667
|
-
to the lower-level filler,
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
667
|
+
to the lower-level filler, which draws image/signature values onto the page
|
|
668
|
+
and removes only the consumed widgets. Other annotations and unfilled
|
|
669
|
+
signature/image fields are preserved. After image drawing, the widget cache
|
|
670
|
+
is refreshed to remove consumed fields while retaining the middleware objects
|
|
671
|
+
for surviving fields so subsequent style updates can still use them.
|
|
671
672
|
Supplied and retained values are prepared before any assignments or PDF
|
|
672
673
|
hooks are applied, so an invalid choice index leaves widget values,
|
|
673
674
|
pending hooks, and PDF state unchanged. Only supplied values are assigned.
|
|
@@ -707,22 +708,11 @@ class PdfWrapper:
|
|
|
707
708
|
readonly=kwargs.get("readonly", False),
|
|
708
709
|
)
|
|
709
710
|
|
|
711
|
+
self._stream = (
|
|
712
|
+
image_drawn_stream if image_drawn_stream is not None else filled_stream
|
|
713
|
+
)
|
|
710
714
|
if image_drawn_stream is not None:
|
|
711
|
-
|
|
712
|
-
k for k, v in self.widgets.items() if not isinstance(v, Signature)
|
|
713
|
-
] # only copy non-image fields
|
|
714
|
-
# Case: Single watermark PDF, mapping pages 1:1 to output pages.
|
|
715
|
-
filled_stream = copy_watermark_widgets(
|
|
716
|
-
remove_all_widgets(image_drawn_stream),
|
|
717
|
-
filled_stream,
|
|
718
|
-
keys_to_copy,
|
|
719
|
-
None,
|
|
720
|
-
)
|
|
721
|
-
|
|
722
|
-
self._stream = filled_stream
|
|
723
|
-
if image_drawn_stream is not None:
|
|
724
|
-
# because copy_watermark_widgets and remove_all_widgets
|
|
725
|
-
self._reregister_font()
|
|
715
|
+
self._init_helper()
|
|
726
716
|
|
|
727
717
|
return self
|
|
728
718
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyPDFForm
|
|
3
|
-
Version: 6.0.
|
|
3
|
+
Version: 6.0.2
|
|
4
4
|
Summary: The Python library & CLI for PDF forms.
|
|
5
5
|
Author: Jinge Li
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,7 +27,7 @@ License-File: LICENSE
|
|
|
27
27
|
Requires-Dist: cryptography<51.0.0,>=50.0.0
|
|
28
28
|
Requires-Dist: fonttools<5.0.0,>=4.63.0
|
|
29
29
|
Requires-Dist: pikepdf<11.0.0,>=10.7.2
|
|
30
|
-
Requires-Dist: pillow<
|
|
30
|
+
Requires-Dist: pillow<14.0.0,>=12.2.0
|
|
31
31
|
Requires-Dist: pypdf<7.0.0,>=6.12.2
|
|
32
32
|
Requires-Dist: reportlab<6.0.0,>=4.5.1
|
|
33
33
|
Provides-Extra: cli
|
|
@@ -39,7 +39,7 @@ Requires-Dist: coverage<8.0.0,>=7.14.1; extra == "dev"
|
|
|
39
39
|
Requires-Dist: mike<3.0.0,>=2.2.0; extra == "dev"
|
|
40
40
|
Requires-Dist: mkdocs<2.0.0,>=1.6.1; extra == "dev"
|
|
41
41
|
Requires-Dist: mkdocs-material<10.0.0,>=9.7.6; extra == "dev"
|
|
42
|
-
Requires-Dist: pudb<
|
|
42
|
+
Requires-Dist: pudb<2027.0.0,>=2025.1.5; extra == "dev"
|
|
43
43
|
Requires-Dist: pylint<5.0.0,>=4.0.5; extra == "dev"
|
|
44
44
|
Requires-Dist: pyright<2.0.0,>=1.1.409; extra == "dev"
|
|
45
45
|
Requires-Dist: pytest<10.0.0,>=9.0.3; extra == "dev"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
cryptography<51.0.0,>=50.0.0
|
|
2
2
|
fonttools<5.0.0,>=4.63.0
|
|
3
3
|
pikepdf<11.0.0,>=10.7.2
|
|
4
|
-
pillow<
|
|
4
|
+
pillow<14.0.0,>=12.2.0
|
|
5
5
|
pypdf<7.0.0,>=6.12.2
|
|
6
6
|
reportlab<6.0.0,>=4.5.1
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ coverage<8.0.0,>=7.14.1
|
|
|
15
15
|
mike<3.0.0,>=2.2.0
|
|
16
16
|
mkdocs<2.0.0,>=1.6.1
|
|
17
17
|
mkdocs-material<10.0.0,>=9.7.6
|
|
18
|
-
pudb<
|
|
18
|
+
pudb<2027.0.0,>=2025.1.5
|
|
19
19
|
pylint<5.0.0,>=4.0.5
|
|
20
20
|
pyright<2.0.0,>=1.1.409
|
|
21
21
|
pytest<10.0.0,>=9.0.3
|
|
@@ -32,7 +32,7 @@ dependencies = [
|
|
|
32
32
|
"cryptography>=50.0.0,<51.0.0",
|
|
33
33
|
"fonttools>=4.63.0,<5.0.0",
|
|
34
34
|
"pikepdf>=10.7.2,<11.0.0",
|
|
35
|
-
"pillow>=12.2.0,<
|
|
35
|
+
"pillow>=12.2.0,<14.0.0",
|
|
36
36
|
"pypdf>=6.12.2,<7.0.0",
|
|
37
37
|
"reportlab>=4.5.1,<6.0.0",
|
|
38
38
|
]
|
|
@@ -53,7 +53,7 @@ dev = [
|
|
|
53
53
|
"mike>=2.2.0,<3.0.0",
|
|
54
54
|
"mkdocs>=1.6.1,<2.0.0",
|
|
55
55
|
"mkdocs-material>=9.7.6,<10.0.0",
|
|
56
|
-
"pudb>=2025.1.5,<
|
|
56
|
+
"pudb>=2025.1.5,<2027.0.0",
|
|
57
57
|
"pylint>=4.0.5,<5.0.0",
|
|
58
58
|
"pyright>=1.1.409,<2.0.0",
|
|
59
59
|
"pytest>=9.0.3,<10.0.0",
|
|
@@ -12,8 +12,10 @@ import pytest
|
|
|
12
12
|
from fontTools.ttLib import TTLibError
|
|
13
13
|
from pypdf import PdfReader, PdfWriter
|
|
14
14
|
from pypdf.generic import NameObject
|
|
15
|
+
from reportlab.pdfbase.pdfmetrics import _fonts
|
|
16
|
+
from reportlab.pdfbase.ttfonts import TTFError, TTFont
|
|
15
17
|
|
|
16
|
-
from PyPDFForm import PdfWrapper
|
|
18
|
+
from PyPDFForm import PdfWrapper, RawElements
|
|
17
19
|
from PyPDFForm.lib.constants import (
|
|
18
20
|
DEFAULT_ASSUMED_GLYPH_WIDTH,
|
|
19
21
|
DR,
|
|
@@ -24,7 +26,100 @@ from PyPDFForm.lib.constants import (
|
|
|
24
26
|
MissingWidth,
|
|
25
27
|
Widths,
|
|
26
28
|
)
|
|
27
|
-
from PyPDFForm.lib.font import compute_font_glyph_widths
|
|
29
|
+
from PyPDFForm.lib.font import compute_font_glyph_widths, temporary_font_registration
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def font_registry():
|
|
34
|
+
original = dict(_fonts)
|
|
35
|
+
yield _fonts
|
|
36
|
+
# Restore shared state even when a regression assertion fails.
|
|
37
|
+
_fonts.clear()
|
|
38
|
+
_fonts.update(original)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_temporary_font_registration_cleans_up_setup_failure(
|
|
42
|
+
sample_font_stream, font_registry
|
|
43
|
+
):
|
|
44
|
+
original = dict(font_registry)
|
|
45
|
+
fonts = [("same", sample_font_stream)] * 2 + [("bad", b"invalid font")]
|
|
46
|
+
with pytest.raises(TTFError), temporary_font_registration(fonts):
|
|
47
|
+
pytest.fail("Invalid font setup must fail before entering the context")
|
|
48
|
+
|
|
49
|
+
assert font_registry == original
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_temporary_font_registration_preserves_other_registrations_on_failure(
|
|
53
|
+
sample_font_stream, font_registry
|
|
54
|
+
):
|
|
55
|
+
original = dict(font_registry)
|
|
56
|
+
error = RuntimeError("drawing failed")
|
|
57
|
+
external = TTFont("external", BytesIO(sample_font_stream))
|
|
58
|
+
|
|
59
|
+
def fail_drawing():
|
|
60
|
+
font_registry["external"] = external
|
|
61
|
+
raise error
|
|
62
|
+
|
|
63
|
+
with temporary_font_registration([("same", sample_font_stream)]):
|
|
64
|
+
outer_state = dict(font_registry)
|
|
65
|
+
with (
|
|
66
|
+
pytest.raises(RuntimeError, match="drawing failed") as caught,
|
|
67
|
+
temporary_font_registration([("same", sample_font_stream)] * 2),
|
|
68
|
+
):
|
|
69
|
+
fail_drawing()
|
|
70
|
+
|
|
71
|
+
assert caught.value is error
|
|
72
|
+
assert font_registry == {**outer_state, "external": external}
|
|
73
|
+
|
|
74
|
+
assert font_registry == {**original, "external": external}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_temporary_font_registration_only_removes_owned_entries(
|
|
78
|
+
sample_font_stream, font_registry
|
|
79
|
+
):
|
|
80
|
+
existing = TTFont("existing", BytesIO(sample_font_stream))
|
|
81
|
+
font_registry["existing"] = existing
|
|
82
|
+
original = dict(font_registry)
|
|
83
|
+
with patch("PyPDFForm.lib.font.uuid4") as uuid:
|
|
84
|
+
uuid.side_effect = [MagicMock(hex="existing"), MagicMock(hex="temporary")]
|
|
85
|
+
with temporary_font_registration([("same", sample_font_stream)]) as mapping:
|
|
86
|
+
assert mapping == {"same": "temporary"}
|
|
87
|
+
assert font_registry["existing"] is existing
|
|
88
|
+
font_registry[mapping["same"]] = existing
|
|
89
|
+
|
|
90
|
+
assert font_registry == {**original, "temporary": existing}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("need_appearances", [False, True])
|
|
94
|
+
def test_repeated_draws_with_duplicate_font_aliases(
|
|
95
|
+
template_stream, sample_font_stream, font_samples, font_registry, need_appearances
|
|
96
|
+
):
|
|
97
|
+
# Initialize ReportLab's lazily loaded built-in fonts before taking a snapshot.
|
|
98
|
+
PdfWrapper(template_stream).draw([RawElements.RawText("Warm up", 1, 100, 100)])
|
|
99
|
+
form = PdfWrapper(template_stream, need_appearances=need_appearances)
|
|
100
|
+
form.register_font("same", os.path.join(font_samples, "LiberationSerif-Italic.ttf"))
|
|
101
|
+
form.register_font("same", sample_font_stream)
|
|
102
|
+
original = dict(font_registry)
|
|
103
|
+
original_fields = set(form.widgets)
|
|
104
|
+
|
|
105
|
+
for index in range(3):
|
|
106
|
+
text = f"Font lifecycle {index}"
|
|
107
|
+
form.draw([RawElements.RawText(text, 1, 100, 100 + index * 25, font="same")])
|
|
108
|
+
stream = form.read()
|
|
109
|
+
assert font_registry == original
|
|
110
|
+
|
|
111
|
+
reopened = PdfWrapper(stream)
|
|
112
|
+
assert set(reopened.widgets) == original_fields
|
|
113
|
+
assert "LiberationSerif-BoldItalic" in reopened.fonts
|
|
114
|
+
page = PdfReader(BytesIO(stream)).pages[0]
|
|
115
|
+
assert text in page.extract_text()
|
|
116
|
+
assert any(
|
|
117
|
+
font["/BaseFont"].endswith("LiberationSerif-BoldItalic")
|
|
118
|
+
and font["/FontDescriptor"]["/FontFile2"].get_data()
|
|
119
|
+
for font in (
|
|
120
|
+
ref.get_object() for ref in page["/Resources"]["/Font"].values()
|
|
121
|
+
)
|
|
122
|
+
)
|
|
28
123
|
|
|
29
124
|
|
|
30
125
|
@pytest.fixture
|