PyPDFForm 6.0.2__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 (86) hide show
  1. {pypdfform-6.0.2 → pypdfform-6.1.0}/PKG-INFO +2 -2
  2. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/__init__.py +2 -3
  3. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/common.py +16 -2
  4. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/create.py +5 -2
  5. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/update.py +18 -7
  6. {pypdfform-6.0.2 → 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.2 → pypdfform-6.1.0}/PyPDFForm/lib/constants.py +21 -0
  9. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/deprecation.py +47 -2
  10. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/base.py +53 -1
  11. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/checkbox.py +2 -0
  12. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/dropdown.py +2 -0
  13. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/text.py +12 -0
  14. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/image.py +4 -3
  15. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/template.py +51 -8
  16. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/utils.py +117 -13
  17. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/watermark.py +18 -5
  18. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/wrapper.py +139 -12
  19. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/PKG-INFO +2 -2
  20. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/SOURCES.txt +0 -1
  21. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/requires.txt +1 -1
  22. {pypdfform-6.0.2 → pypdfform-6.1.0}/pyproject.toml +2 -1
  23. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_functional.py +261 -4
  24. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_signature.py +14 -3
  25. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_widget_attr_trigger.py +61 -0
  26. pypdfform-6.0.2/PyPDFForm/lib/annotations/link.py +0 -68
  27. pypdfform-6.0.2/PyPDFForm/lib/types.py +0 -64
  28. {pypdfform-6.0.2 → pypdfform-6.1.0}/LICENSE +0 -0
  29. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/__init__.py +0 -0
  30. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/entry.py +0 -0
  31. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/inspect.py +0 -0
  32. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/remove.py +0 -0
  33. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/root.py +0 -0
  34. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/__init__.py +0 -0
  35. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/create.py +0 -0
  36. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/cli/schemas/update.py +0 -0
  37. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/__init__.py +0 -0
  38. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/__init__.py +0 -0
  39. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/base.py +0 -0
  40. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/stamp.py +0 -0
  41. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/text.py +0 -0
  42. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  43. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/assets/__init__.py +0 -0
  44. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/assets/blank.py +0 -0
  45. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/coordinate.py +0 -0
  46. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/egress.py +0 -0
  47. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/filler.py +0 -0
  48. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/font.py +0 -0
  49. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/hooks.py +0 -0
  50. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/image.py +0 -0
  51. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/__init__.py +0 -0
  52. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/image.py +0 -0
  53. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/radio.py +0 -0
  54. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/middleware/signature.py +0 -0
  55. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/patterns.py +0 -0
  56. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/__init__.py +0 -0
  57. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/circle.py +0 -0
  58. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/ellipse.py +0 -0
  59. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/line.py +0 -0
  60. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/rect.py +0 -0
  61. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/raw/text.py +0 -0
  62. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/__init__.py +0 -0
  63. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/base.py +0 -0
  64. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  65. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  66. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/image.py +0 -0
  67. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/radio.py +0 -0
  68. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/signature.py +0 -0
  69. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm/lib/widgets/text.py +0 -0
  70. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  71. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/entry_points.txt +0 -0
  72. {pypdfform-6.0.2 → pypdfform-6.1.0}/PyPDFForm.egg-info/top_level.txt +0 -0
  73. {pypdfform-6.0.2 → pypdfform-6.1.0}/README.md +0 -0
  74. {pypdfform-6.0.2 → pypdfform-6.1.0}/setup.cfg +0 -0
  75. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_bulk_create_fields.py +0 -0
  76. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_create_widget.py +0 -0
  77. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_draw_elements.py +0 -0
  78. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_dropdown.py +0 -0
  79. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_extract_middleware_attributes.py +0 -0
  80. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_fill_max_length_text_field.py +0 -0
  81. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_font_related.py +0 -0
  82. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_generate_appearance_streams.py +0 -0
  83. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_js.py +0 -0
  84. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_need_appearances.py +0 -0
  85. {pypdfform-6.0.2 → pypdfform-6.1.0}/tests/test_paragraph.py +0 -0
  86. {pypdfform-6.0.2 → 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.2
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
@@ -28,7 +28,7 @@ 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
30
  Requires-Dist: pillow<14.0.0,>=12.2.0
31
- Requires-Dist: pypdf<7.0.0,>=6.12.2
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"
@@ -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.2"
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"
@@ -90,6 +110,7 @@ A = "/A"
90
110
  AA = "/AA"
91
111
  Action = "/Action"
92
112
  S = "/S"
113
+ GoTo = "/GoTo"
93
114
  JavaScript = "/JavaScript"
94
115
  JS = "/JS"
95
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)
@@ -39,8 +39,28 @@ class Widget:
39
39
  built from existing PDF form fields. It stores extracted field state,
40
40
  queues hook calls when mutable attributes are changed, and exposes schema
41
41
  and sample-data helpers used by `PdfWrapper`.
42
+
43
+ Attributes:
44
+ TYPE_SPECIFIC_PROPERTIES (frozenset): Properties supported only by this
45
+ widget type and its subclasses. Subclasses declare their own.
42
46
  """
43
47
 
48
+ TYPE_SPECIFIC_PROPERTIES = frozenset()
49
+ _ALL_TYPE_SPECIFIC_PROPERTIES = set()
50
+
51
+ def __init_subclass__(cls, **kwargs) -> None:
52
+ """
53
+ Registers the type-specific properties declared by a subclass.
54
+
55
+ The registry holds the properties declared by every widget type, so a
56
+ widget can reject properties that only other widget types support.
57
+
58
+ Args:
59
+ **kwargs: Keyword arguments passed to the parent hook.
60
+ """
61
+ super().__init_subclass__(**kwargs)
62
+ Widget._ALL_TYPE_SPECIFIC_PROPERTIES.update(cls.TYPE_SPECIFIC_PROPERTIES)
63
+
44
64
  def __init__(
45
65
  self,
46
66
  name: str,
@@ -106,12 +126,19 @@ class Widget:
106
126
  This method overrides the default __setattr__ method to
107
127
  trigger hooks when certain attributes are set to non-None values. It also
108
128
  tracks existing attributes that were explicitly set so those values can be
109
- preserved across widget-cache rebuilds.
129
+ preserved across widget-cache rebuilds. Type-specific properties that the
130
+ widget does not support are rejected before any change.
110
131
 
111
132
  Args:
112
133
  name (str): The name of the attribute.
113
134
  value (Any): The value of the attribute.
135
+
136
+ Raises:
137
+ AttributeError: Raised when the widget type does not support the
138
+ property.
114
139
  """
140
+ self.check_property_supported(name)
141
+
115
142
  if (
116
143
  hasattr(self, "SET_ATTR_TRIGGER_HOOK_MAP")
117
144
  and name in self.SET_ATTR_TRIGGER_HOOK_MAP
@@ -128,6 +155,31 @@ class Widget:
128
155
 
129
156
  super().__setattr__(name, value)
130
157
 
158
+ def check_property_supported(self, name: str) -> None:
159
+ """
160
+ Checks whether the widget type supports a property.
161
+
162
+ Type-specific properties, such as `max_length` or `size`, are supported
163
+ only by widget types that declare them in `TYPE_SPECIFIC_PROPERTIES`.
164
+ Other attribute names are not checked.
165
+
166
+ Args:
167
+ name (str): The name of the property.
168
+
169
+ Raises:
170
+ AttributeError: Raised when the widget type does not support the
171
+ property.
172
+ """
173
+ if (
174
+ name in Widget._ALL_TYPE_SPECIFIC_PROPERTIES
175
+ and name not in self.TYPE_SPECIFIC_PROPERTIES
176
+ ):
177
+ msg = (
178
+ f"{type(self).__name__} field '{self.name}' does not support "
179
+ f"property '{name}'."
180
+ )
181
+ raise AttributeError(msg)
182
+
131
183
  @property
132
184
  def name(self) -> str:
133
185
  """
@@ -20,6 +20,8 @@ class Checkbox(Widget):
20
20
  implements the schema_definition and sample_value properties.
21
21
  """
22
22
 
23
+ TYPE_SPECIFIC_PROPERTIES = frozenset({"size"})
24
+
23
25
  def __init__(
24
26
  self,
25
27
  name: str,
@@ -30,6 +30,8 @@ class Dropdown(Widget):
30
30
  sample_value: Returns a sample value for the dropdown.
31
31
  """
32
32
 
33
+ TYPE_SPECIFIC_PROPERTIES = frozenset({"font", "font_size", "font_color", "choices"})
34
+
33
35
  def __init__(
34
36
  self,
35
37
  name: str,
@@ -24,6 +24,18 @@ class Text(Widget):
24
24
  font_size, font_color, comb, alignment, and multiline.
25
25
  """
26
26
 
27
+ TYPE_SPECIFIC_PROPERTIES = frozenset(
28
+ {
29
+ "font",
30
+ "font_size",
31
+ "font_color",
32
+ "comb",
33
+ "alignment",
34
+ "multiline",
35
+ "max_length",
36
+ }
37
+ )
38
+
27
39
  def __init__(
28
40
  self,
29
41
  name: str,
@@ -5,6 +5,7 @@ Contains the RawImage class, which represents an image that can be drawn
5
5
  directly onto a PDF page at a specified position and size.
6
6
  """
7
7
 
8
+ from os import PathLike
8
9
  from typing import BinaryIO
9
10
 
10
11
  from ..adapter import fp_or_f_obj_or_stream_to_stream
@@ -22,7 +23,7 @@ class RawImage:
22
23
 
23
24
  def __init__(
24
25
  self,
25
- image: bytes | str | BinaryIO,
26
+ image: bytes | str | PathLike | BinaryIO,
26
27
  page_number: int,
27
28
  x: float,
28
29
  y: float,
@@ -34,8 +35,8 @@ class RawImage:
34
35
  Initializes a raw image object for drawing.
35
36
 
36
37
  Args:
37
- image: The image source, which can be a path (str), raw bytes (bytes),
38
- or a file stream (BinaryIO).
38
+ image: The image source, which can be a path (str or PathLike), raw bytes (bytes),
39
+ or a file stream (BinaryIO). A missing path raises when the image is drawn.
39
40
  page_number: The 1-based index of the page where the image should be drawn.
40
41
  x: The x-coordinate (horizontal position) of the bottom-left corner of the image.
41
42
  y: The y-coordinate (vertical position) of the bottom-left corner of the image.
@@ -14,9 +14,15 @@ from io import BytesIO
14
14
  from typing import Dict, List, cast
15
15
 
16
16
  from pypdf import PdfReader, PdfWriter
17
- from pypdf.generic import ArrayObject, DictionaryObject, NameObject, TextStringObject
17
+ from pypdf.generic import (
18
+ ArrayObject,
19
+ DictionaryObject,
20
+ IndirectObject,
21
+ NameObject,
22
+ TextStringObject,
23
+ )
18
24
 
19
- from .annotations import AnnotationTypes
25
+ from .annotations import AnnotationTypes, LinkAnnotation
20
26
  from .constants import (
21
27
  COMB,
22
28
  JS,
@@ -24,6 +30,7 @@ from .constants import (
24
30
  READ_ONLY,
25
31
  REQUIRED,
26
32
  Annots,
33
+ Dest,
27
34
  JavaScript,
28
35
  OpenAction,
29
36
  S,
@@ -497,6 +504,32 @@ def _group_annotations_by_page(
497
504
  return result
498
505
 
499
506
 
507
+ def _get_annotation_properties(
508
+ annotation: AnnotationTypes,
509
+ page_references: List[IndirectObject],
510
+ ) -> dict:
511
+ """
512
+ Builds the PDF dictionary of an annotation for a target PDF.
513
+
514
+ A link to an internal page gets a destination that refers to the target
515
+ page in `page_references`.
516
+
517
+ Args:
518
+ annotation (AnnotationTypes): The annotation object to build.
519
+ page_references (List[IndirectObject]): References to the pages of the
520
+ target PDF, in page order.
521
+
522
+ Returns:
523
+ dict: The PDF dictionary of the annotation.
524
+ """
525
+ result = annotation.get_specific_properties()
526
+ if isinstance(annotation, LinkAnnotation):
527
+ destination = annotation.get_destination(page_references)
528
+ if destination is not None:
529
+ result[NameObject(Dest)] = destination
530
+ return result
531
+
532
+
500
533
  def create_annotations(
501
534
  template: bytes,
502
535
  annotations: List[AnnotationTypes],
@@ -507,7 +540,8 @@ def create_annotations(
507
540
  This function takes a PDF template and a list of annotation objects, builds
508
541
  their PDF dictionaries, and appends them to the `/Annots` array for each
509
542
  target page. Existing annotations are preserved, and annotation page numbers
510
- are 1-based.
543
+ are 1-based. Links to internal pages refer to the target page objects, and
544
+ every annotation is built before any page changes.
511
545
 
512
546
  Args:
513
547
  template (bytes): The PDF template to add annotations to.
@@ -516,19 +550,28 @@ def create_annotations(
516
550
 
517
551
  Returns:
518
552
  bytes: The updated PDF stream with the added annotations.
553
+
554
+ Raises:
555
+ ValueError: If a link points to a page outside the PDF.
519
556
  """
520
557
  writer = PdfWriter(BytesIO(template))
521
- annotations_by_page = _group_annotations_by_page(annotations)
558
+ page_references = [page.indirect_reference for page in writer.pages]
559
+ annotations_by_page = {
560
+ page_num: [
561
+ _get_annotation_properties(annotation, page_references)
562
+ for annotation in page_annotations
563
+ ]
564
+ for page_num, page_annotations in _group_annotations_by_page(
565
+ annotations
566
+ ).items()
567
+ }
522
568
 
523
569
  for i, page in enumerate(writer.pages):
524
570
  page_num = i + 1
525
571
  if page_num not in annotations_by_page:
526
572
  continue
527
573
 
528
- page_annotations = ArrayObject([])
529
- for annotation in annotations_by_page[page_num]:
530
- page_annotations.append(annotation.get_specific_properties())
531
-
574
+ page_annotations = ArrayObject(annotations_by_page[page_num])
532
575
  if Annots in page:
533
576
  page[NameObject(Annots)] += page_annotations
534
577
  else: