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.
Files changed (89) hide show
  1. {pypdfform-6.0.0 → pypdfform-6.0.2}/PKG-INFO +3 -3
  2. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/__init__.py +1 -1
  3. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/constants.py +1 -0
  4. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/egress.py +73 -17
  5. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/filler.py +52 -8
  6. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/font.py +27 -12
  7. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/hooks.py +67 -26
  8. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/base.py +48 -1
  9. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/checkbox.py +2 -2
  10. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/dropdown.py +85 -14
  11. pypdfform-6.0.2/PyPDFForm/lib/middleware/radio.py +129 -0
  12. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/text.py +1 -1
  13. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/patterns.py +26 -20
  14. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/wrapper.py +51 -39
  15. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/PKG-INFO +3 -3
  16. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/SOURCES.txt +1 -1
  17. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/requires.txt +2 -2
  18. {pypdfform-6.0.0 → pypdfform-6.0.2}/pyproject.toml +2 -2
  19. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_extract_middleware_attributes.py +2 -2
  20. pypdfform-6.0.2/tests/test_font_related.py +303 -0
  21. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_functional.py +408 -3
  22. pypdfform-6.0.2/tests/test_signature.py +447 -0
  23. pypdfform-6.0.2/tests/test_use_full_widget_name.py +116 -0
  24. pypdfform-6.0.0/PyPDFForm/lib/middleware/radio.py +0 -72
  25. pypdfform-6.0.0/tests/test_font_widths.py +0 -123
  26. pypdfform-6.0.0/tests/test_signature.py +0 -136
  27. pypdfform-6.0.0/tests/test_use_full_widget_name.py +0 -55
  28. {pypdfform-6.0.0 → pypdfform-6.0.2}/LICENSE +0 -0
  29. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/__init__.py +0 -0
  30. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/common.py +0 -0
  31. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/create.py +0 -0
  32. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/entry.py +0 -0
  33. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/inspect.py +0 -0
  34. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/remove.py +0 -0
  35. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/root.py +0 -0
  36. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/__init__.py +0 -0
  37. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/create.py +0 -0
  38. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/update.py +0 -0
  39. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/cli/update.py +0 -0
  40. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/__init__.py +0 -0
  41. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/adapter.py +0 -0
  42. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/__init__.py +0 -0
  43. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/base.py +0 -0
  44. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/link.py +0 -0
  45. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/stamp.py +0 -0
  46. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text.py +0 -0
  47. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  48. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/assets/__init__.py +0 -0
  49. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/assets/blank.py +0 -0
  50. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/coordinate.py +0 -0
  51. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/deprecation.py +0 -0
  52. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/image.py +0 -0
  53. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/__init__.py +0 -0
  54. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/image.py +0 -0
  55. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/signature.py +0 -0
  56. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/__init__.py +0 -0
  57. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/circle.py +0 -0
  58. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/ellipse.py +0 -0
  59. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/image.py +0 -0
  60. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/line.py +0 -0
  61. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/rect.py +0 -0
  62. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/raw/text.py +0 -0
  63. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/template.py +0 -0
  64. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/types.py +0 -0
  65. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/utils.py +0 -0
  66. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/watermark.py +0 -0
  67. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/__init__.py +0 -0
  68. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/base.py +0 -0
  69. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  70. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  71. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/image.py +0 -0
  72. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/radio.py +0 -0
  73. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/signature.py +0 -0
  74. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/text.py +0 -0
  75. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  76. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/entry_points.txt +0 -0
  77. {pypdfform-6.0.0 → pypdfform-6.0.2}/PyPDFForm.egg-info/top_level.txt +0 -0
  78. {pypdfform-6.0.0 → pypdfform-6.0.2}/README.md +0 -0
  79. {pypdfform-6.0.0 → pypdfform-6.0.2}/setup.cfg +0 -0
  80. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_bulk_create_fields.py +0 -0
  81. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_create_widget.py +0 -0
  82. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_draw_elements.py +0 -0
  83. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_dropdown.py +0 -0
  84. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_fill_max_length_text_field.py +0 -0
  85. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_generate_appearance_streams.py +0 -0
  86. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_js.py +0 -0
  87. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_need_appearances.py +0 -0
  88. {pypdfform-6.0.0 → pypdfform-6.0.2}/tests/test_paragraph.py +0 -0
  89. {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.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<13.0.0,>=12.2.0
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<2026.0.0,>=2025.1.5; extra == "dev"
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.0"
19
+ __version__ = "6.0.2"
20
20
 
21
21
  from .lib.annotations import Annotations
22
22
  from .lib.assets.blank import BlankPage
@@ -56,6 +56,7 @@ TU = "/TU"
56
56
  Rect = "/Rect"
57
57
  FT = "/FT"
58
58
  Parent = "/Parent"
59
+ Kids = "/Kids"
59
60
  Ff = "/Ff"
60
61
  F = "/F"
61
62
  Tx = "/Tx"
@@ -1,17 +1,22 @@
1
1
  # -*- coding: utf-8 -*-
2
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 rebuilding the AcroForm `/Fields` array from
9
- the widget annotations present on each page. These functions are typically called
10
- right before the final PDF byte stream is returned by the wrapper module.
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. Each page annotation is resolved to a widget key, and only
89
- annotations whose keys are present in `widget_keys` contribute their
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
- key = get_widget_key(annot.get_object(), use_full_widget_name)
115
- if key in widget_keys:
116
- field_ref = _get_root_field_reference(writer, annot)
117
- field_key = _field_reference_key(field_ref)
118
- if field_key not in seen_fields:
119
- fields.append(field_ref)
120
- seen_fields.add(field_key)
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 Annots
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, and
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 returns both the filled form stream and a second stream with
173
- the image watermarks applied so callers can preserve non-image widgets separately.
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
- for annot in page.get(Annots, []):
200
- annot = cast(DictionaryObject, annot.get_object())
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
- any_image_to_draw |= update_widget(
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 and removed again on exit.
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
- for font_name, ttf_stream in fonts:
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 font_mapping.values():
211
- if rl_name in _fonts:
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 look like `/F#`
386
- and returns the lowest unused numbered key with the same prefix.
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 key.startswith(FONT_NAME_PREFIX):
397
- existing.add(int(key[2:]))
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 update_check_radio_size(annot: DictionaryObject, val: float) -> None:
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. Non-float values are ignored.
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
- if isinstance(val, float):
395
- diff = val - annot[Rect][0]
396
- annot[NameObject(Rect)][0] = FloatObject(val)
397
- annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)
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. Non-float values are ignored.
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
- if isinstance(val, float):
413
- diff = val - annot[Rect][1]
414
- annot[NameObject(Rect)][1] = FloatObject(val)
415
- annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)
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. Non-float values are ignored.
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
- if isinstance(val, float):
431
- annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)
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. Non-float values are ignored.
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
- if isinstance(val, float):
447
- annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)
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._value = value
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