PyPDFForm 6.0.1__tar.gz → 6.1.0__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-6.0.1 → pypdfform-6.1.0}/PKG-INFO +4 -4
  2. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/__init__.py +2 -3
  3. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/common.py +16 -2
  4. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/create.py +5 -2
  5. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/update.py +18 -7
  6. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/adapter.py +17 -9
  7. pypdfform-6.1.0/PyPDFForm/lib/annotations/link.py +98 -0
  8. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/constants.py +22 -0
  9. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/deprecation.py +47 -2
  10. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/egress.py +73 -17
  11. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/filler.py +52 -8
  12. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/font.py +20 -8
  13. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/base.py +53 -1
  14. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/checkbox.py +2 -0
  15. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/dropdown.py +2 -0
  16. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/text.py +12 -0
  17. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/patterns.py +10 -9
  18. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/image.py +4 -3
  19. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/template.py +51 -8
  20. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/utils.py +117 -13
  21. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/watermark.py +18 -5
  22. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/wrapper.py +158 -41
  23. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/PKG-INFO +4 -4
  24. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/SOURCES.txt +0 -1
  25. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/requires.txt +3 -3
  26. {pypdfform-6.0.1 → pypdfform-6.1.0}/pyproject.toml +4 -3
  27. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_font_related.py +97 -2
  28. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_functional.py +591 -124
  29. pypdfform-6.1.0/tests/test_signature.py +458 -0
  30. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_widget_attr_trigger.py +61 -0
  31. pypdfform-6.0.1/PyPDFForm/lib/annotations/link.py +0 -68
  32. pypdfform-6.0.1/PyPDFForm/lib/types.py +0 -64
  33. pypdfform-6.0.1/tests/test_signature.py +0 -136
  34. {pypdfform-6.0.1 → pypdfform-6.1.0}/LICENSE +0 -0
  35. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/__init__.py +0 -0
  36. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/entry.py +0 -0
  37. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/inspect.py +0 -0
  38. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/remove.py +0 -0
  39. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/root.py +0 -0
  40. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/__init__.py +0 -0
  41. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/create.py +0 -0
  42. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/update.py +0 -0
  43. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/__init__.py +0 -0
  44. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/__init__.py +0 -0
  45. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/base.py +0 -0
  46. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/stamp.py +0 -0
  47. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/text.py +0 -0
  48. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  49. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/assets/__init__.py +0 -0
  50. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/assets/blank.py +0 -0
  51. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/coordinate.py +0 -0
  52. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/hooks.py +0 -0
  53. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/image.py +0 -0
  54. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/__init__.py +0 -0
  55. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/image.py +0 -0
  56. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/radio.py +0 -0
  57. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/signature.py +0 -0
  58. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/__init__.py +0 -0
  59. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/circle.py +0 -0
  60. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/ellipse.py +0 -0
  61. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/line.py +0 -0
  62. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/rect.py +0 -0
  63. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/raw/text.py +0 -0
  64. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/__init__.py +0 -0
  65. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/base.py +0 -0
  66. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  67. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  68. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/image.py +0 -0
  69. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/radio.py +0 -0
  70. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/signature.py +0 -0
  71. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/text.py +0 -0
  72. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  73. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/entry_points.txt +0 -0
  74. {pypdfform-6.0.1 → pypdfform-6.1.0}/PyPDFForm.egg-info/top_level.txt +0 -0
  75. {pypdfform-6.0.1 → pypdfform-6.1.0}/README.md +0 -0
  76. {pypdfform-6.0.1 → pypdfform-6.1.0}/setup.cfg +0 -0
  77. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_bulk_create_fields.py +0 -0
  78. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_create_widget.py +0 -0
  79. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_draw_elements.py +0 -0
  80. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_dropdown.py +0 -0
  81. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_extract_middleware_attributes.py +0 -0
  82. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_fill_max_length_text_field.py +0 -0
  83. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_generate_appearance_streams.py +0 -0
  84. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_js.py +0 -0
  85. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_need_appearances.py +0 -0
  86. {pypdfform-6.0.1 → pypdfform-6.1.0}/tests/test_paragraph.py +0 -0
  87. {pypdfform-6.0.1 → pypdfform-6.1.0}/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: 6.0.1
3
+ Version: 6.1.0
4
4
  Summary: The Python library & CLI for PDF forms.
5
5
  Author: Jinge Li
6
6
  License-Expression: MIT
@@ -27,8 +27,8 @@ 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
31
- Requires-Dist: pypdf<7.0.0,>=6.12.2
30
+ Requires-Dist: pillow<14.0.0,>=12.2.0
31
+ Requires-Dist: pypdf<7.0.0,>=6.19.0
32
32
  Requires-Dist: reportlab<6.0.0,>=4.5.1
33
33
  Provides-Extra: cli
34
34
  Requires-Dist: typer<1.0.0,>=0.26.1; 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,15 +16,14 @@ 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.1.0"
20
20
 
21
21
  from .lib.annotations import Annotations
22
22
  from .lib.assets.blank import BlankPage
23
23
  from .lib.middleware import Widgets
24
24
  from .lib.raw import RawElements
25
- from .lib.types import PdfArray
26
25
  from .lib.widgets import Fields
27
- from .lib.wrapper import PdfWrapper
26
+ from .lib.wrapper import PdfArray, PdfWrapper
28
27
 
29
28
  __all__ = [
30
29
  "PdfWrapper",
@@ -312,6 +312,7 @@ def create_elements_from_file(
312
312
  options_param_hint: str,
313
313
  output: Path | None = None,
314
314
  element_type: str | None = None,
315
+ input_errors: tuple[type[Exception], ...] = (),
315
316
  ) -> None:
316
317
  """
317
318
  Creates PDF elements from a data file or command-line options.
@@ -321,7 +322,8 @@ def create_elements_from_file(
321
322
  from the additional command-line options stored in ``ctx.args``. The data
322
323
  file takes precedence when both input forms are supplied. Each definition
323
324
  is constructed after optional font registration and passed to `method_name`
324
- on `PdfWrapper`.
325
+ on `PdfWrapper`. Errors of the `input_errors` types raised by that method
326
+ are reported against the input form that was used, without writing output.
325
327
 
326
328
  Args:
327
329
  pdf (Path): The path to the input PDF file.
@@ -342,6 +344,9 @@ def create_elements_from_file(
342
344
  omitted, the input PDF is overwritten. Defaults to None.
343
345
  element_type (str, optional): Element type selected for command-line
344
346
  input. Defaults to None.
347
+ input_errors (tuple[type[Exception], ...], optional): Exception types
348
+ raised by `method_name` for invalid element definitions. Defaults to
349
+ an empty tuple.
345
350
  """
346
351
  if data is not None:
347
352
  input_data = load_data_file(data, schema, file_param_hint)
@@ -370,4 +375,13 @@ def create_elements_from_file(
370
375
  handle_font_registration(obj, each, registered_font)
371
376
  ungrouped_input.append(element_map[k](**each))
372
377
 
373
- getattr(obj, method_name)(ungrouped_input).write(output or pdf)
378
+ try:
379
+ result = getattr(obj, method_name)(ungrouped_input)
380
+ except input_errors as exc:
381
+ cli_bad_parameter(
382
+ str(exc),
383
+ param_hint=file_param_hint if data is not None else options_param_hint,
384
+ cause=exc,
385
+ )
386
+
387
+ result.write(output or pdf)
@@ -126,7 +126,8 @@ def extract(
126
126
  The command validates that the requested end page does not precede the
127
127
  start page, converts the 1-based CLI page numbers into the slice expected
128
128
  by `PdfWrapper.pages`, and writes the extracted pages to the required
129
- output path.
129
+ output path. Each page is extracted separately, so links to other pages
130
+ are removed.
130
131
 
131
132
  Args:
132
133
  ctx (typer.Context): Typer context containing global `PdfWrapper`
@@ -332,7 +333,8 @@ def annotation(
332
333
  The command maps input groups such as `text`, `link`, and `highlight` to
333
334
  annotation classes, validates the input against the CLI annotation schema,
334
335
  creates the corresponding annotation objects, and calls
335
- `PdfWrapper.annotate` before writing the modified PDF. A data file can
336
+ `PdfWrapper.annotate` before writing the modified PDF. A link to a page
337
+ outside the PDF is reported as a parameter error. A data file can
336
338
  define multiple annotations grouped by type. Without `--file`, `--type` and
337
339
  the annotation options define one annotation. The data file takes
338
340
  precedence when both input forms are supplied.
@@ -368,6 +370,7 @@ def annotation(
368
370
  options_param_hint="annotation options",
369
371
  output=output,
370
372
  element_type=element_type,
373
+ input_errors=(ValueError,),
371
374
  )
372
375
 
373
376
 
@@ -297,9 +297,10 @@ def field(
297
297
 
298
298
  The command validates the input against the field update schema, loads the
299
299
  PDF with the global CLI options stored in `ctx.obj`, resolves each field
300
- name, registers any referenced fonts once per invocation, assigns the
301
- requested widget properties, and writes the modified PDF to the requested
302
- output path or back to the input file. A data file can update multiple
300
+ name, checks that each field type supports its requested properties,
301
+ registers any referenced fonts once per invocation, assigns the requested
302
+ widget properties, and writes the modified PDF to the requested output path
303
+ or back to the input file. A data file can update multiple
303
304
  fields. Without `--file`, `--field` and the property options update one
304
305
  field. The data file takes precedence when both input forms are supplied.
305
306
 
@@ -315,8 +316,9 @@ def field(
315
316
  Defaults to None.
316
317
 
317
318
  Raises:
318
- typer.BadParameter: Raised when the input file is invalid or a requested
319
- field does not exist.
319
+ typer.BadParameter: Raised when the input file is invalid, a requested
320
+ field does not exist, or a field type does not support a requested
321
+ property.
320
322
  """
321
323
  if data is not None:
322
324
  input_data = load_data_file(data, FIELD_SCHEMA, "--file")
@@ -337,12 +339,21 @@ def field(
337
339
  field_param_hint = "--field"
338
340
 
339
341
  obj = PdfWrapper(str(pdf), **ctx.obj)
340
- registered_font = {}
342
+ field_widgets = {}
341
343
  for k, each in input_data.items():
342
344
  field_widget = get_widget(obj, k, field_param_hint)
345
+ for param in each:
346
+ try:
347
+ field_widget.check_property_supported(param)
348
+ except AttributeError as exc:
349
+ cli_bad_parameter(str(exc), param_hint=field_param_hint, cause=exc)
350
+ field_widgets[k] = field_widget
351
+
352
+ registered_font = {}
353
+ for k, each in input_data.items():
343
354
  handle_font_registration(obj, each, registered_font)
344
355
  for param, v in each.items():
345
- setattr(field_widget, param, v)
356
+ setattr(field_widgets[k], param, v)
346
357
 
347
358
  obj.write(output or pdf)
348
359
 
@@ -10,6 +10,7 @@ forms. The module ensures that the input is properly converted into a byte
10
10
  stream before further processing.
11
11
  """
12
12
 
13
+ from os import PathLike
13
14
  from os.path import isfile
14
15
  from typing import Any, BinaryIO
15
16
 
@@ -32,7 +33,7 @@ def readable(obj: Any) -> bool:
32
33
 
33
34
 
34
35
  def fp_or_f_obj_or_stream_to_stream(
35
- fp_or_f_obj_or_stream: bytes | str | BinaryIO,
36
+ fp_or_f_obj_or_stream: bytes | str | PathLike | BinaryIO,
36
37
  ) -> bytes:
37
38
  """
38
39
  Adapt a file path, file object, or stream to a byte stream.
@@ -40,30 +41,37 @@ def fp_or_f_obj_or_stream_to_stream(
40
41
  This function takes a file path, a file object, or a byte stream and adapts it to a consistent byte stream.
41
42
  It handles different input types, including:
42
43
  - byte streams (bytes)
43
- - file paths (str)
44
+ - file paths (str or PathLike)
44
45
  - file-like objects with a read() method (BinaryIO)
45
- Unsupported inputs and string paths that do not point to files are treated
46
- as empty input and return ``b""``.
46
+ Paths are opened directly, so a missing path raises ``FileNotFoundError``.
47
+ Empty bytes and empty readable objects are returned as ``b""``.
47
48
 
48
49
  Args:
49
- fp_or_f_obj_or_stream (bytes | str | BinaryIO): The input to adapt.
50
- It can be a byte stream, a file path (string), or a file object.
50
+ fp_or_f_obj_or_stream (bytes | str | PathLike | BinaryIO): The input to adapt.
51
+ It can be a byte stream, a file path, or a file object.
51
52
 
52
53
  Returns:
53
54
  bytes: The byte stream representation of the input.
54
- Returns an empty byte string if the file path does not exist.
55
+
56
+ Raises:
57
+ OSError: If a file path cannot be opened, such as ``FileNotFoundError``
58
+ for a missing path.
59
+ TypeError: If the input is not bytes, a file path, or a readable object.
55
60
  """
56
61
  # not cached to handle writing to the same disk file
57
- result = b""
58
62
  if isinstance(fp_or_f_obj_or_stream, bytes):
59
63
  result = fp_or_f_obj_or_stream
60
64
 
61
65
  elif readable(fp_or_f_obj_or_stream):
62
66
  result = fp_or_f_obj_or_stream.read()
63
67
 
64
- elif isinstance(fp_or_f_obj_or_stream, str) and isfile(fp_or_f_obj_or_stream):
68
+ elif isinstance(fp_or_f_obj_or_stream, (str, PathLike)):
65
69
  with open(fp_or_f_obj_or_stream, "rb") as _file:
66
70
  result = _file.read()
71
+
72
+ else:
73
+ msg = f"Expected bytes, a file path, or a readable binary stream, got {type(fp_or_f_obj_or_stream).__name__}."
74
+ raise TypeError(msg)
67
75
  return result
68
76
 
69
77
 
@@ -0,0 +1,98 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This module defines the class for link annotations in PyPDFForm.
4
+
5
+ It provides a structure for representing and interacting with PDF link
6
+ annotations, which allow users to click and navigate to a URI or an internal page.
7
+
8
+ Classes:
9
+ - `LinkAnnotation`: A dataclass representing the properties of a PDF link annotation.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Optional, Sequence
14
+
15
+ from pypdf.generic import (
16
+ ArrayObject,
17
+ DictionaryObject,
18
+ IndirectObject,
19
+ NameObject,
20
+ TextStringObject,
21
+ )
22
+
23
+ from ..constants import A, Link, S
24
+ from .base import Annotation
25
+
26
+
27
+ @dataclass
28
+ class LinkAnnotation(Annotation):
29
+ """
30
+ A dataclass representing the properties of a PDF link annotation.
31
+
32
+ This class extends the `Annotation` base class to specifically handle
33
+ link annotations, including the target URI or an internal page.
34
+
35
+ Attributes:
36
+ uri (str): The URI that the link annotation points to. Takes precedence
37
+ over `page` when both are set. Defaults to None.
38
+ page (int): The 1-based page number in the same PDF that the link
39
+ annotation points to. Defaults to None.
40
+ """
41
+
42
+ _annotation_type: str = Link
43
+
44
+ uri: Optional[str] = None
45
+ page: Optional[int] = None
46
+
47
+ def get_specific_properties(self) -> dict:
48
+ """
49
+ Gets properties specific to the link annotation.
50
+
51
+ This method extends the base properties with a URI action. A link to
52
+ an internal page needs the pages of the target PDF, so its destination
53
+ is built separately by `get_destination`.
54
+
55
+ Returns:
56
+ dict: A dictionary of PDF properties specific to the link annotation.
57
+ """
58
+ result = super().get_specific_properties()
59
+ if self.uri is not None:
60
+ result[NameObject(A)] = DictionaryObject(
61
+ {
62
+ NameObject(S): NameObject("/URI"),
63
+ NameObject("/URI"): TextStringObject(self.uri),
64
+ }
65
+ )
66
+
67
+ return result
68
+
69
+ def get_destination(
70
+ self, page_references: Sequence[IndirectObject]
71
+ ) -> Optional[ArrayObject]:
72
+ """
73
+ Gets the destination of a link to an internal page.
74
+
75
+ A destination in the same PDF must refer to the target page object
76
+ rather than a page index, which is reserved for destinations in other
77
+ PDFs. The 1-based `page` is resolved against the page references of the
78
+ PDF that will contain the link.
79
+
80
+ Args:
81
+ page_references (Sequence[IndirectObject]): References to the pages
82
+ of the PDF that will contain the link, in page order.
83
+
84
+ Returns:
85
+ Optional[ArrayObject]: The destination that fits the target page in
86
+ the window, or None if the link has a URI or no page.
87
+
88
+ Raises:
89
+ ValueError: If `page` is outside the pages of the PDF.
90
+ """
91
+ if self.uri is not None or self.page is None:
92
+ return None
93
+
94
+ if not 1 <= self.page <= len(page_references):
95
+ msg = f"Link page {self.page} must satisfy 1 <= page <= {len(page_references)}."
96
+ raise ValueError(msg)
97
+
98
+ return ArrayObject([page_references[self.page - 1], NameObject("/Fit")])
@@ -41,10 +41,30 @@ VERSION_IDENTIFIERS = [
41
41
 
42
42
  DEPRECATION_NOTICE = "{} will be deprecated soon."
43
43
  DEPRECATION_REPLACE_NOTICE = "Use {} instead."
44
+ UNEXPECTED_KWARG_NOTICE = "{}() got an unexpected keyword argument '{}'."
45
+ REMOVED_KWARG_NOTICE = "It was removed in PyPDFForm {}. {}"
46
+ SUPPORTED_KWARGS_NOTICE = "Supported keyword arguments: {}."
47
+ # method name -> removed keyword -> (version removed, migration guidance)
48
+ REMOVED_KWARGS = {
49
+ "__init__": {
50
+ "preserve_metadata": (
51
+ "6.0.0",
52
+ "Remove it; input PDF metadata is preserved automatically.",
53
+ ),
54
+ },
55
+ "fill": {
56
+ "flatten": (
57
+ "6.0.0",
58
+ "Use readonly=True to make fields read-only; there is no form-flattening replacement.",
59
+ ),
60
+ },
61
+ }
44
62
 
45
63
  Title = "/Title"
46
64
  Annots = "/Annots"
47
65
  Annot = "/Annot"
66
+ Link = "/Link"
67
+ Dest = "/Dest"
48
68
  Contents = "/Contents"
49
69
  Widget = "/Widget"
50
70
  XObject = "/XObject"
@@ -56,6 +76,7 @@ TU = "/TU"
56
76
  Rect = "/Rect"
57
77
  FT = "/FT"
58
78
  Parent = "/Parent"
79
+ Kids = "/Kids"
59
80
  Ff = "/Ff"
60
81
  F = "/F"
61
82
  Tx = "/Tx"
@@ -89,6 +110,7 @@ A = "/A"
89
110
  AA = "/AA"
90
111
  Action = "/Action"
91
112
  S = "/S"
113
+ GoTo = "/GoTo"
92
114
  JavaScript = "/JavaScript"
93
115
  JS = "/JS"
94
116
  OpenAction = "/OpenAction"
@@ -4,13 +4,22 @@ A module for handling deprecation notices within the PyPDFForm library.
4
4
 
5
5
  This module provides utility functions to issue standard DeprecationWarning
6
6
  messages, ensuring consistency across the library when notifying users of
7
- deprecated features.
7
+ deprecated features. It also rejects keyword arguments that are unsupported
8
+ or were removed after deprecation.
8
9
  """
9
10
 
10
11
  from functools import wraps
12
+ from typing import Any, Sequence
11
13
  from warnings import warn
12
14
 
13
- from .constants import DEPRECATION_NOTICE, DEPRECATION_REPLACE_NOTICE
15
+ from .constants import (
16
+ DEPRECATION_NOTICE,
17
+ DEPRECATION_REPLACE_NOTICE,
18
+ REMOVED_KWARG_NOTICE,
19
+ REMOVED_KWARGS,
20
+ SUPPORTED_KWARGS_NOTICE,
21
+ UNEXPECTED_KWARG_NOTICE,
22
+ )
14
23
 
15
24
 
16
25
  def deprecation_notice(to_replace: str, param: str = "") -> callable:
@@ -72,3 +81,39 @@ def deprecation_notice(to_replace: str, param: str = "") -> callable:
72
81
 
73
82
  decorator.emit_notice = emit_notice
74
83
  return decorator
84
+
85
+
86
+ def reject_unsupported_kwargs(
87
+ obj: Any, method_name: str, kwargs: dict, supported: Sequence[str]
88
+ ) -> None:
89
+ """
90
+ Raises a TypeError for the first keyword argument a method does not support.
91
+
92
+ The message names the keyword like Python's own unexpected-keyword error.
93
+ Keywords listed for the method in `REMOVED_KWARGS` add the version that
94
+ removed them and their migration guidance; other keywords list the
95
+ supported ones.
96
+
97
+ Args:
98
+ obj (Any): The object whose method received the keyword arguments.
99
+ method_name (str): The method name, such as "__init__" or "fill".
100
+ kwargs (dict): The keyword arguments passed to the method.
101
+ supported (Sequence[str]): The keyword arguments the method supports.
102
+
103
+ Raises:
104
+ TypeError: If any keyword argument is not supported.
105
+ """
106
+
107
+ name = obj.__class__.__name__
108
+ if method_name != "__init__":
109
+ name = f"{name}.{method_name}"
110
+ removed = REMOVED_KWARGS.get(method_name, {})
111
+
112
+ for key in kwargs:
113
+ if key not in supported:
114
+ msg = UNEXPECTED_KWARG_NOTICE.format(name, key)
115
+ if key in removed:
116
+ msg = f"{msg} {REMOVED_KWARG_NOTICE.format(*removed[key])}"
117
+ else:
118
+ msg = f"{msg} {SUPPORTED_KWARGS_NOTICE.format(', '.join(supported))}"
119
+ raise TypeError(msg)
@@ -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)