PyPDFForm 5.2.3__tar.gz → 5.2.5__tar.gz

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