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.
Files changed (86) hide show
  1. {pypdfform-6.0.1 → pypdfform-6.0.2}/PKG-INFO +3 -3
  2. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/__init__.py +1 -1
  3. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/constants.py +1 -0
  4. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/egress.py +73 -17
  5. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/filler.py +52 -8
  6. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/font.py +20 -8
  7. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/patterns.py +10 -9
  8. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/wrapper.py +19 -29
  9. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/PKG-INFO +3 -3
  10. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/requires.txt +2 -2
  11. {pypdfform-6.0.1 → pypdfform-6.0.2}/pyproject.toml +2 -2
  12. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_font_related.py +97 -2
  13. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_functional.py +330 -120
  14. pypdfform-6.0.2/tests/test_signature.py +447 -0
  15. pypdfform-6.0.1/tests/test_signature.py +0 -136
  16. {pypdfform-6.0.1 → pypdfform-6.0.2}/LICENSE +0 -0
  17. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/__init__.py +0 -0
  18. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/common.py +0 -0
  19. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/create.py +0 -0
  20. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/entry.py +0 -0
  21. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/inspect.py +0 -0
  22. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/remove.py +0 -0
  23. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/root.py +0 -0
  24. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/__init__.py +0 -0
  25. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/create.py +0 -0
  26. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/schemas/update.py +0 -0
  27. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/cli/update.py +0 -0
  28. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/__init__.py +0 -0
  29. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/adapter.py +0 -0
  30. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/__init__.py +0 -0
  31. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/base.py +0 -0
  32. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/link.py +0 -0
  33. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/stamp.py +0 -0
  34. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text.py +0 -0
  35. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  36. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/assets/__init__.py +0 -0
  37. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/assets/blank.py +0 -0
  38. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/coordinate.py +0 -0
  39. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/deprecation.py +0 -0
  40. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/hooks.py +0 -0
  41. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/image.py +0 -0
  42. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/__init__.py +0 -0
  43. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/base.py +0 -0
  44. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/checkbox.py +0 -0
  45. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/dropdown.py +0 -0
  46. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/image.py +0 -0
  47. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/radio.py +0 -0
  48. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/signature.py +0 -0
  49. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/middleware/text.py +0 -0
  50. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/__init__.py +0 -0
  51. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/circle.py +0 -0
  52. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/ellipse.py +0 -0
  53. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/image.py +0 -0
  54. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/line.py +0 -0
  55. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/rect.py +0 -0
  56. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/raw/text.py +0 -0
  57. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/template.py +0 -0
  58. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/types.py +0 -0
  59. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/utils.py +0 -0
  60. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/watermark.py +0 -0
  61. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/__init__.py +0 -0
  62. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/base.py +0 -0
  63. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  64. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  65. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/image.py +0 -0
  66. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/radio.py +0 -0
  67. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/signature.py +0 -0
  68. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm/lib/widgets/text.py +0 -0
  69. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/SOURCES.txt +0 -0
  70. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  71. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/entry_points.txt +0 -0
  72. {pypdfform-6.0.1 → pypdfform-6.0.2}/PyPDFForm.egg-info/top_level.txt +0 -0
  73. {pypdfform-6.0.1 → pypdfform-6.0.2}/README.md +0 -0
  74. {pypdfform-6.0.1 → pypdfform-6.0.2}/setup.cfg +0 -0
  75. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_bulk_create_fields.py +0 -0
  76. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_create_widget.py +0 -0
  77. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_draw_elements.py +0 -0
  78. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_dropdown.py +0 -0
  79. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_extract_middleware_attributes.py +0 -0
  80. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_fill_max_length_text_field.py +0 -0
  81. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_generate_appearance_streams.py +0 -0
  82. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_js.py +0 -0
  83. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_need_appearances.py +0 -0
  84. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_paragraph.py +0 -0
  85. {pypdfform-6.0.1 → pypdfform-6.0.2}/tests/test_use_full_widget_name.py +0 -0
  86. {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.1
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.1"
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)
@@ -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 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.
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
- for font_name, ttf_stream in fonts:
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 font_mapping.values():
212
- if rl_name in _fonts:
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
- Updates the value of a radio button annotation, selecting it.
264
+ Selects a radio button and clears every other widget in its group.
264
265
 
265
- This function modifies the appearance state (AS) and value (V) of the radio button's
266
- parent dictionary to reflect the selected state. It also removes the parent's
267
- Opt entry when present so the selected appearance state is used directly.
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[NameObject(AS)] = NameObject(each)
278
- annot[NameObject(Parent)][NameObject(V)] = NameObject(each)
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
- Writes the PDF to a file.
579
+ Serialize the PDF, then write it to a path or an open binary stream.
580
580
 
581
- String, bytes, and PathLike destinations are opened in binary write mode.
582
- Other objects are treated as already-open writable binary streams.
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): The destination to write the PDF to.
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
- with open(dest, "wb+") as f:
594
- f.write(self.read())
594
+ write_path(dest, content)
595
595
  else:
596
- dest.write(self.read())
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, then handles the special image/signature path by
668
- drawing those values as watermarks and copying the remaining widgets back onto
669
- the output. The wrapper's widget cache is intentionally left in place so
670
- subsequent style updates can still refer to the same middleware objects.
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
- keys_to_copy = [
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.1
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"
@@ -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<13.0.0,>=12.2.0
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<2026.0.0,>=2025.1.5
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,<13.0.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,<2026.0.0",
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