PyPDFForm 6.0.0__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.0 → pypdfform-6.0.2}/PKG-INFO +3 -3
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/__init__.py +1 -1
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/constants.py +1 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/egress.py +73 -17
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/filler.py +52 -8
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/font.py +27 -12
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/hooks.py +67 -26
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/base.py +48 -1
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/checkbox.py +2 -2
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/dropdown.py +85 -14
- pypdfform-6.0.2/PyPDFForm/lib/middleware/radio.py +129 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/text.py +1 -1
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/patterns.py +26 -20
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/wrapper.py +51 -39
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/PKG-INFO +3 -3
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/SOURCES.txt +1 -1
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/requires.txt +2 -2
- {pypdfform-6.0.0 → pypdfform-6.0.2}/pyproject.toml +2 -2
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_extract_middleware_attributes.py +2 -2
- pypdfform-6.0.2/tests/test_font_related.py +303 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_functional.py +408 -3
- pypdfform-6.0.2/tests/test_signature.py +447 -0
- pypdfform-6.0.2/tests/test_use_full_widget_name.py +116 -0
- pypdfform-6.0.0/PyPDFForm/lib/middleware/radio.py +0 -72
- pypdfform-6.0.0/tests/test_font_widths.py +0 -123
- pypdfform-6.0.0/tests/test_signature.py +0 -136
- pypdfform-6.0.0/tests/test_use_full_widget_name.py +0 -55
- {pypdfform-6.0.0 → pypdfform-6.0.2}/LICENSE +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/common.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/create.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/entry.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/inspect.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/remove.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/root.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/create.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/update.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/update.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/adapter.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/base.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/link.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/stamp.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text_markup.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/assets/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/assets/blank.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/coordinate.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/deprecation.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/image.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/image.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/signature.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/circle.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/ellipse.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/image.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/line.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/rect.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/text.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/template.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/types.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/utils.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/watermark.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/__init__.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/base.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/checkbox.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/dropdown.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/image.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/radio.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/signature.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/text.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/dependency_links.txt +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/entry_points.txt +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/top_level.txt +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/README.md +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/setup.cfg +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_bulk_create_fields.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_create_widget.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_draw_elements.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_dropdown.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_fill_max_length_text_field.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_generate_appearance_streams.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_js.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_need_appearances.py +0 -0
- {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_paragraph.py +0 -0
- {pypdfform-6.0.0 → 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)
|
|
@@ -10,6 +10,7 @@ for extracting font information from TTF streams and managing font names within
|
|
|
10
10
|
from contextlib import contextmanager
|
|
11
11
|
from functools import lru_cache
|
|
12
12
|
from io import BytesIO
|
|
13
|
+
from re import fullmatch
|
|
13
14
|
from typing import Generator
|
|
14
15
|
from uuid import uuid4
|
|
15
16
|
from zlib import compress
|
|
@@ -190,7 +191,14 @@ def temporary_font_registration(
|
|
|
190
191
|
from the original font names to the unique names.
|
|
191
192
|
|
|
192
193
|
The fonts are inserted into ReportLab's global font registry for the duration
|
|
193
|
-
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.
|
|
194
202
|
|
|
195
203
|
Args:
|
|
196
204
|
fonts (list[tuple[str, bytes]]): A list of tuples, each containing a font name and its TTF stream.
|
|
@@ -199,16 +207,21 @@ def temporary_font_registration(
|
|
|
199
207
|
dict: A mapping of the original font names to the temporary unique names used by ReportLab.
|
|
200
208
|
"""
|
|
201
209
|
font_mapping = {}
|
|
202
|
-
|
|
203
|
-
rl_name = uuid4().hex
|
|
204
|
-
font_mapping[font_name] = rl_name
|
|
205
|
-
_fonts[rl_name] = TTFont(rl_name, BytesIO(ttf_stream))
|
|
206
|
-
|
|
210
|
+
registered_fonts = {}
|
|
207
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
|
+
|
|
208
221
|
yield font_mapping
|
|
209
222
|
finally:
|
|
210
|
-
for rl_name in
|
|
211
|
-
if rl_name
|
|
223
|
+
for rl_name, font in registered_fonts.items():
|
|
224
|
+
if _fonts.get(rl_name) is font:
|
|
212
225
|
del _fonts[rl_name]
|
|
213
226
|
|
|
214
227
|
|
|
@@ -382,8 +395,10 @@ def _get_new_font_name(fonts: dict) -> str:
|
|
|
382
395
|
"""
|
|
383
396
|
Generates a new unique font name to avoid conflicts with existing fonts in the PDF.
|
|
384
397
|
|
|
385
|
-
This function inspects existing AcroForm resource keys that
|
|
386
|
-
|
|
398
|
+
This function inspects existing AcroForm resource keys that exactly match `/F`
|
|
399
|
+
followed by one or more ASCII decimal digits and returns the lowest unused
|
|
400
|
+
numbered key with the same prefix. Other resource names are left unchanged
|
|
401
|
+
and do not reserve a number.
|
|
387
402
|
|
|
388
403
|
Args:
|
|
389
404
|
fonts (dict): A dictionary of existing fonts in the PDF.
|
|
@@ -393,8 +408,8 @@ def _get_new_font_name(fonts: dict) -> str:
|
|
|
393
408
|
"""
|
|
394
409
|
existing = set()
|
|
395
410
|
for key in fonts:
|
|
396
|
-
if isinstance(key, str) and
|
|
397
|
-
existing.add(int(key[
|
|
411
|
+
if isinstance(key, str) and fullmatch(rf"{FONT_NAME_PREFIX}[0-9]+", key):
|
|
412
|
+
existing.add(int(key[len(FONT_NAME_PREFIX) :]))
|
|
398
413
|
|
|
399
414
|
n = 1
|
|
400
415
|
while n in existing:
|
|
@@ -314,7 +314,28 @@ def update_text_field_max_length(annot: DictionaryObject, val: int) -> None:
|
|
|
314
314
|
annot[NameObject(MaxLen)] = NumberObject(val)
|
|
315
315
|
|
|
316
316
|
|
|
317
|
-
def
|
|
317
|
+
def _normalize_geometry_value(val: int | float) -> float:
|
|
318
|
+
"""
|
|
319
|
+
Normalizes scalar geometry to a float before modifying an annotation.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
val (int | float): The coordinate or dimension to normalize.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
float: The normalized geometry value.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
TypeError: If the value is not an integer or float, or is a boolean.
|
|
329
|
+
"""
|
|
330
|
+
# bool subclasses int, so the numeric type check alone would accept booleans.
|
|
331
|
+
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
|
332
|
+
# TODO: Refactor this raise into a centralized exception raiser when one is introduced.
|
|
333
|
+
msg = "Geometry values must be integers or floats; booleans are invalid."
|
|
334
|
+
raise TypeError(msg)
|
|
335
|
+
return float(val)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def update_check_radio_size(annot: DictionaryObject, val: int | float) -> None:
|
|
318
339
|
"""
|
|
319
340
|
Updates the size of a check box or radio button annotation.
|
|
320
341
|
|
|
@@ -325,8 +346,12 @@ def update_check_radio_size(annot: DictionaryObject, val: float) -> None:
|
|
|
325
346
|
Args:
|
|
326
347
|
annot (DictionaryObject): The annotation dictionary for the check box or
|
|
327
348
|
radio button.
|
|
328
|
-
val (float): The new size (width and height) for the check box or radio button.
|
|
349
|
+
val (int | float): The new size (width and height) for the check box or radio button.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
TypeError: If the size is not an integer or float, or is a boolean.
|
|
329
353
|
"""
|
|
354
|
+
val = _normalize_geometry_value(val)
|
|
330
355
|
rect = annot[Rect]
|
|
331
356
|
# scale from bottom left
|
|
332
357
|
new_rect = [
|
|
@@ -379,72 +404,88 @@ def update_field_readonly(annot: DictionaryObject, val: bool) -> None:
|
|
|
379
404
|
_update_field_flag(annot, READ_ONLY, val)
|
|
380
405
|
|
|
381
406
|
|
|
382
|
-
def update_field_x(annot: DictionaryObject, val: float) -> None:
|
|
407
|
+
def update_field_x(annot: DictionaryObject, val: int | float) -> None:
|
|
383
408
|
"""
|
|
384
409
|
Updates the X-coordinate (horizontal position) of a form field annotation.
|
|
385
410
|
|
|
386
411
|
This function modifies the 'Rect' entry of the annotation dictionary to change
|
|
387
412
|
the left X-coordinate of the field and adjusts the right X-coordinate by the
|
|
388
|
-
same delta to maintain the field's width.
|
|
413
|
+
same delta to maintain the field's width. Integer and float inputs are
|
|
414
|
+
normalized to floats before updating the rectangle.
|
|
389
415
|
|
|
390
416
|
Args:
|
|
391
417
|
annot (DictionaryObject): The annotation dictionary for the form field.
|
|
392
|
-
val (float): The new X-coordinate for the left edge of the field.
|
|
418
|
+
val (int | float): The new X-coordinate for the left edge of the field.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
TypeError: If the coordinate is not an integer or float, or is a boolean.
|
|
393
422
|
"""
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
423
|
+
val = _normalize_geometry_value(val)
|
|
424
|
+
diff = val - annot[Rect][0]
|
|
425
|
+
annot[NameObject(Rect)][0] = FloatObject(val)
|
|
426
|
+
annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)
|
|
398
427
|
|
|
399
428
|
|
|
400
|
-
def update_field_y(annot: DictionaryObject, val: float) -> None:
|
|
429
|
+
def update_field_y(annot: DictionaryObject, val: int | float) -> None:
|
|
401
430
|
"""
|
|
402
431
|
Updates the Y-coordinate (vertical position) of a form field annotation.
|
|
403
432
|
|
|
404
433
|
This function modifies the 'Rect' entry of the annotation dictionary to change
|
|
405
434
|
the bottom Y-coordinate of the field and adjusts the top Y-coordinate by the
|
|
406
|
-
same delta to maintain the field's height.
|
|
435
|
+
same delta to maintain the field's height. Integer and float inputs are
|
|
436
|
+
normalized to floats before updating the rectangle.
|
|
407
437
|
|
|
408
438
|
Args:
|
|
409
439
|
annot (DictionaryObject): The annotation dictionary for the form field.
|
|
410
|
-
val (float): The new Y-coordinate for the bottom edge of the field.
|
|
440
|
+
val (int | float): The new Y-coordinate for the bottom edge of the field.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
TypeError: If the coordinate is not an integer or float, or is a boolean.
|
|
411
444
|
"""
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
445
|
+
val = _normalize_geometry_value(val)
|
|
446
|
+
diff = val - annot[Rect][1]
|
|
447
|
+
annot[NameObject(Rect)][1] = FloatObject(val)
|
|
448
|
+
annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)
|
|
416
449
|
|
|
417
450
|
|
|
418
|
-
def update_field_width(annot: DictionaryObject, val: float) -> None:
|
|
451
|
+
def update_field_width(annot: DictionaryObject, val: int | float) -> None:
|
|
419
452
|
"""
|
|
420
453
|
Updates the width of a form field annotation.
|
|
421
454
|
|
|
422
455
|
This function modifies the 'Rect' entry of the annotation dictionary to set
|
|
423
456
|
the new width of the field, adjusting the rightmost coordinate while keeping
|
|
424
|
-
the leftmost coordinate fixed.
|
|
457
|
+
the leftmost coordinate fixed. Integer and float inputs are normalized to
|
|
458
|
+
floats before updating the rectangle.
|
|
425
459
|
|
|
426
460
|
Args:
|
|
427
461
|
annot (DictionaryObject): The annotation dictionary for the form field.
|
|
428
|
-
val (float): The new width of the field.
|
|
462
|
+
val (int | float): The new width of the field.
|
|
463
|
+
|
|
464
|
+
Raises:
|
|
465
|
+
TypeError: If the width is not an integer or float, or is a boolean.
|
|
429
466
|
"""
|
|
430
|
-
|
|
431
|
-
|
|
467
|
+
val = _normalize_geometry_value(val)
|
|
468
|
+
annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)
|
|
432
469
|
|
|
433
470
|
|
|
434
|
-
def update_field_height(annot: DictionaryObject, val: float) -> None:
|
|
471
|
+
def update_field_height(annot: DictionaryObject, val: int | float) -> None:
|
|
435
472
|
"""
|
|
436
473
|
Updates the height of a form field annotation.
|
|
437
474
|
|
|
438
475
|
This function modifies the 'Rect' entry of the annotation dictionary to set
|
|
439
476
|
the new height of the field, adjusting the topmost coordinate while keeping
|
|
440
|
-
the bottommost coordinate fixed.
|
|
477
|
+
the bottommost coordinate fixed. Integer and float inputs are normalized to
|
|
478
|
+
floats before updating the rectangle.
|
|
441
479
|
|
|
442
480
|
Args:
|
|
443
481
|
annot (DictionaryObject): The annotation dictionary for the form field.
|
|
444
|
-
val (float): The new height of the field.
|
|
482
|
+
val (int | float): The new height of the field.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
TypeError: If the height is not an integer or float, or is a boolean.
|
|
445
486
|
"""
|
|
446
|
-
|
|
447
|
-
|
|
487
|
+
val = _normalize_geometry_value(val)
|
|
488
|
+
annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)
|
|
448
489
|
|
|
449
490
|
|
|
450
491
|
def update_field_tooltip(annot: DictionaryObject, val: str) -> None:
|
|
@@ -8,9 +8,29 @@ common attributes and methods for all form widgets, such as name, value,
|
|
|
8
8
|
and schema definition.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
from dataclasses import dataclass
|
|
11
12
|
from typing import Any, List, Optional, TextIO
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PreparedValue:
|
|
17
|
+
"""
|
|
18
|
+
Represents a widget value prepared for assignment.
|
|
19
|
+
|
|
20
|
+
Preparation validates and normalizes the value without changing the widget.
|
|
21
|
+
Dropdown updates can also include replacement choices, which are stored and
|
|
22
|
+
queued for PDF updates only when the prepared value is applied.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
value (Any): The value to store in the widget.
|
|
26
|
+
choices (list | None): Replacement dropdown choices, or None when the
|
|
27
|
+
choices should remain unchanged.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
value: Any
|
|
31
|
+
choices: list | None = None
|
|
32
|
+
|
|
33
|
+
|
|
14
34
|
class Widget:
|
|
15
35
|
"""
|
|
16
36
|
Base class for form widget.
|
|
@@ -136,7 +156,34 @@ class Widget:
|
|
|
136
156
|
Args:
|
|
137
157
|
value (Any): The value to set.
|
|
138
158
|
"""
|
|
139
|
-
self.
|
|
159
|
+
self.apply_value(self.prepare_value(value))
|
|
160
|
+
|
|
161
|
+
def prepare_value(self, value: Any) -> PreparedValue:
|
|
162
|
+
"""
|
|
163
|
+
Prepares a value for assignment without changing the widget.
|
|
164
|
+
|
|
165
|
+
The base implementation retains the supplied value. Subclasses can
|
|
166
|
+
validate or normalize it and stage related attribute changes here.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
value (Any): The proposed value of the widget.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
PreparedValue: The value to apply after preparation succeeds.
|
|
173
|
+
"""
|
|
174
|
+
return PreparedValue(value)
|
|
175
|
+
|
|
176
|
+
def apply_value(self, update: PreparedValue) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Applies a previously prepared value to the widget.
|
|
179
|
+
|
|
180
|
+
This method stores the value without repeating validation. Subclasses
|
|
181
|
+
can apply related attribute changes through their existing hooks.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
update (PreparedValue): The prepared widget value to store.
|
|
185
|
+
"""
|
|
186
|
+
self._value = update.value
|
|
140
187
|
|
|
141
188
|
@property
|
|
142
189
|
def schema_definition(self) -> dict:
|
|
@@ -76,7 +76,7 @@ class Checkbox(Widget):
|
|
|
76
76
|
return {"type": "boolean", **super().schema_definition}
|
|
77
77
|
|
|
78
78
|
@property
|
|
79
|
-
def sample_value(self) -> bool | int:
|
|
79
|
+
def sample_value(self) -> bool | int | None:
|
|
80
80
|
"""
|
|
81
81
|
Returns a sample value for the checkbox.
|
|
82
82
|
|
|
@@ -84,6 +84,6 @@ class Checkbox(Widget):
|
|
|
84
84
|
checkbox field.
|
|
85
85
|
|
|
86
86
|
Returns:
|
|
87
|
-
bool | int: A sample value for the checkbox.
|
|
87
|
+
bool | int | None: A sample value for the checkbox or its subclasses.
|
|
88
88
|
"""
|
|
89
89
|
return True
|