PyPDFForm 5.6.0__tar.gz → 6.0.1__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.6.0 → pypdfform-6.0.1}/PKG-INFO +3 -3
  2. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/__init__.py +1 -10
  3. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/root.py +2 -18
  4. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/font.py +7 -4
  5. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/hooks.py +67 -26
  6. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/base.py +48 -1
  7. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/checkbox.py +2 -2
  8. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/dropdown.py +85 -14
  9. pypdfform-6.0.1/PyPDFForm/lib/middleware/radio.py +129 -0
  10. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/text.py +1 -1
  11. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/patterns.py +16 -11
  12. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/wrapper.py +35 -29
  13. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/PKG-INFO +3 -3
  14. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/SOURCES.txt +1 -1
  15. {pypdfform-5.6.0 → pypdfform-6.0.1}/pyproject.toml +4 -4
  16. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_extract_middleware_attributes.py +2 -2
  17. pypdfform-5.6.0/tests/test_font_widths.py → pypdfform-6.0.1/tests/test_font_related.py +86 -1
  18. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_functional.py +196 -9
  19. pypdfform-6.0.1/tests/test_use_full_widget_name.py +116 -0
  20. pypdfform-5.6.0/PyPDFForm/lib/middleware/radio.py +0 -72
  21. pypdfform-5.6.0/tests/test_use_full_widget_name.py +0 -55
  22. {pypdfform-5.6.0 → pypdfform-6.0.1}/LICENSE +0 -0
  23. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/__init__.py +0 -0
  24. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/common.py +0 -0
  25. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/create.py +0 -0
  26. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/entry.py +0 -0
  27. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/inspect.py +0 -0
  28. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/remove.py +0 -0
  29. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/schemas/__init__.py +0 -0
  30. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/schemas/create.py +0 -0
  31. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/schemas/update.py +0 -0
  32. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/cli/update.py +0 -0
  33. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/__init__.py +0 -0
  34. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/adapter.py +0 -0
  35. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/__init__.py +0 -0
  36. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/base.py +0 -0
  37. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/link.py +0 -0
  38. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/stamp.py +0 -0
  39. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/text.py +0 -0
  40. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  41. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/assets/__init__.py +0 -0
  42. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/assets/blank.py +0 -0
  43. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/constants.py +0 -0
  44. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/coordinate.py +0 -0
  45. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/deprecation.py +0 -0
  46. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/egress.py +0 -0
  47. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/filler.py +0 -0
  48. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/image.py +0 -0
  49. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/__init__.py +0 -0
  50. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/image.py +0 -0
  51. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/middleware/signature.py +0 -0
  52. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/__init__.py +0 -0
  53. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/circle.py +0 -0
  54. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/ellipse.py +0 -0
  55. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/image.py +0 -0
  56. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/line.py +0 -0
  57. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/rect.py +0 -0
  58. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/raw/text.py +0 -0
  59. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/template.py +0 -0
  60. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/types.py +0 -0
  61. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/utils.py +0 -0
  62. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/watermark.py +0 -0
  63. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/__init__.py +0 -0
  64. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/base.py +0 -0
  65. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  66. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  67. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/image.py +0 -0
  68. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/radio.py +0 -0
  69. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/signature.py +0 -0
  70. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm/lib/widgets/text.py +0 -0
  71. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  72. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/entry_points.txt +0 -0
  73. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/requires.txt +0 -0
  74. {pypdfform-5.6.0 → pypdfform-6.0.1}/PyPDFForm.egg-info/top_level.txt +0 -0
  75. {pypdfform-5.6.0 → pypdfform-6.0.1}/README.md +0 -0
  76. {pypdfform-5.6.0 → pypdfform-6.0.1}/setup.cfg +0 -0
  77. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_bulk_create_fields.py +0 -0
  78. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_create_widget.py +0 -0
  79. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_draw_elements.py +0 -0
  80. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_dropdown.py +0 -0
  81. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_fill_max_length_text_field.py +0 -0
  82. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_generate_appearance_streams.py +0 -0
  83. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_js.py +0 -0
  84. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_need_appearances.py +0 -0
  85. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_paragraph.py +0 -0
  86. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_signature.py +0 -0
  87. {pypdfform-5.6.0 → pypdfform-6.0.1}/tests/test_widget_attr_trigger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPDFForm
3
- Version: 5.6.0
3
+ Version: 6.0.1
4
4
  Summary: The Python library & CLI for PDF forms.
5
5
  Author: Jinge Li
6
6
  License-Expression: MIT
@@ -12,16 +12,16 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
20
  Classifier: Topic :: File Formats
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Topic :: Text Processing
23
23
  Classifier: Topic :: Utilities
24
- Requires-Python: >=3.10
24
+ Requires-Python: >=3.11
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: cryptography<51.0.0,>=50.0.0
@@ -16,9 +16,7 @@ PyPDFForm aims to make PDF form automation straightforward whether it is used
16
16
  from Python code or from the command line.
17
17
  """
18
18
 
19
- import logging
20
-
21
- __version__ = "5.6.0"
19
+ __version__ = "6.0.1"
22
20
 
23
21
  from .lib.annotations import Annotations
24
22
  from .lib.assets.blank import BlankPage
@@ -28,13 +26,6 @@ from .lib.types import PdfArray
28
26
  from .lib.widgets import Fields
29
27
  from .lib.wrapper import PdfWrapper
30
28
 
31
- # TODO: figure out why `Annotation sizes differ:`
32
- for logger in [
33
- logging.getLogger(name) for name in getattr(logging.root.manager, "loggerDict")
34
- ]:
35
- if "pypdf" in logger.name:
36
- logger.setLevel(logging.ERROR)
37
-
38
29
  __all__ = [
39
30
  "PdfWrapper",
40
31
  "PdfArray",
@@ -108,14 +108,6 @@ def main(
108
108
  help="Generate form field appearance streams.",
109
109
  ),
110
110
  ] = False,
111
- preserve_metadata: Annotated[
112
- bool,
113
- typer.Option(
114
- "--preserve-metadata",
115
- help="Preserve input PDF metadata.",
116
- hidden=True,
117
- ),
118
- ] = False,
119
111
  use_full_widget_name: Annotated[
120
112
  bool,
121
113
  typer.Option(
@@ -138,15 +130,12 @@ def main(
138
130
  field appearances.
139
131
  generate_appearance_streams (bool): Whether to generate form field
140
132
  appearance streams while handling PDFs.
141
- preserve_metadata (bool): Deprecated compatibility option. Input PDF
142
- metadata is preserved automatically.
143
133
  use_full_widget_name (bool): Whether widget lookups should use full
144
134
  form field names.
145
135
  """
146
136
  ctx.obj = {
147
137
  "need_appearances": need_appearances,
148
138
  "generate_appearance_streams": generate_appearance_streams,
149
- "preserve_metadata": preserve_metadata,
150
139
  "use_full_widget_name": use_full_widget_name,
151
140
  }
152
141
 
@@ -163,10 +152,6 @@ def fill(
163
152
  Path | None, data_file_option("YAML or JSON file with form field values.")
164
153
  ] = None,
165
154
  output: OPTIONAL_OUTPUT_PDF = None,
166
- flatten: Annotated[
167
- bool,
168
- typer.Option("--flatten", help="Deprecated alias for --readonly.", hidden=True),
169
- ] = None,
170
155
  readonly: Annotated[
171
156
  bool,
172
157
  typer.Option("--readonly", help="Make form fields read-only when filling."),
@@ -191,9 +176,8 @@ def fill(
191
176
  options in ``ctx.args``. Defaults to None.
192
177
  output (Path, optional): Output PDF path. If omitted, the input PDF is
193
178
  overwritten. Defaults to None.
194
- flatten (bool, optional): Deprecated alias for `readonly`. Defaults to None.
195
179
  readonly (bool): Whether to make form fields read-only when filling.
196
- Defaults to False. Either `readonly` or `flatten` enables read-only fields.
180
+ Defaults to False.
197
181
  """
198
182
  obj = PdfWrapper(str(pdf), **ctx.obj)
199
183
 
@@ -231,4 +215,4 @@ def fill(
231
215
  )
232
216
  input_data[k] = input_data[k]["path"]
233
217
 
234
- obj.fill(input_data, readonly=readonly, flatten=flatten).write(output or pdf)
218
+ obj.fill(input_data, readonly=readonly).write(output or pdf)
@@ -10,6 +10,7 @@ for extracting font information from TTF streams and managing font names within
10
10
  from contextlib import contextmanager
11
11
  from functools import lru_cache
12
12
  from io import BytesIO
13
+ from re import fullmatch
13
14
  from typing import Generator
14
15
  from uuid import uuid4
15
16
  from zlib import compress
@@ -382,8 +383,10 @@ def _get_new_font_name(fonts: dict) -> str:
382
383
  """
383
384
  Generates a new unique font name to avoid conflicts with existing fonts in the PDF.
384
385
 
385
- This function inspects existing AcroForm resource keys that look like `/F#`
386
- and returns the lowest unused numbered key with the same prefix.
386
+ This function inspects existing AcroForm resource keys that exactly match `/F`
387
+ followed by one or more ASCII decimal digits and returns the lowest unused
388
+ numbered key with the same prefix. Other resource names are left unchanged
389
+ and do not reserve a number.
387
390
 
388
391
  Args:
389
392
  fonts (dict): A dictionary of existing fonts in the PDF.
@@ -393,8 +396,8 @@ def _get_new_font_name(fonts: dict) -> str:
393
396
  """
394
397
  existing = set()
395
398
  for key in fonts:
396
- if isinstance(key, str) and key.startswith(FONT_NAME_PREFIX):
397
- existing.add(int(key[2:]))
399
+ if isinstance(key, str) and fullmatch(rf"{FONT_NAME_PREFIX}[0-9]+", key):
400
+ existing.add(int(key[len(FONT_NAME_PREFIX) :]))
398
401
 
399
402
  n = 1
400
403
  while n in existing:
@@ -314,7 +314,28 @@ def update_text_field_max_length(annot: DictionaryObject, val: int) -> None:
314
314
  annot[NameObject(MaxLen)] = NumberObject(val)
315
315
 
316
316
 
317
- def update_check_radio_size(annot: DictionaryObject, val: float) -> None:
317
+ def _normalize_geometry_value(val: int | float) -> float:
318
+ """
319
+ Normalizes scalar geometry to a float before modifying an annotation.
320
+
321
+ Args:
322
+ val (int | float): The coordinate or dimension to normalize.
323
+
324
+ Returns:
325
+ float: The normalized geometry value.
326
+
327
+ Raises:
328
+ TypeError: If the value is not an integer or float, or is a boolean.
329
+ """
330
+ # bool subclasses int, so the numeric type check alone would accept booleans.
331
+ if isinstance(val, bool) or not isinstance(val, (int, float)):
332
+ # TODO: Refactor this raise into a centralized exception raiser when one is introduced.
333
+ msg = "Geometry values must be integers or floats; booleans are invalid."
334
+ raise TypeError(msg)
335
+ return float(val)
336
+
337
+
338
+ def update_check_radio_size(annot: DictionaryObject, val: int | float) -> None:
318
339
  """
319
340
  Updates the size of a check box or radio button annotation.
320
341
 
@@ -325,8 +346,12 @@ def update_check_radio_size(annot: DictionaryObject, val: float) -> None:
325
346
  Args:
326
347
  annot (DictionaryObject): The annotation dictionary for the check box or
327
348
  radio button.
328
- val (float): The new size (width and height) for the check box or radio button.
349
+ val (int | float): The new size (width and height) for the check box or radio button.
350
+
351
+ Raises:
352
+ TypeError: If the size is not an integer or float, or is a boolean.
329
353
  """
354
+ val = _normalize_geometry_value(val)
330
355
  rect = annot[Rect]
331
356
  # scale from bottom left
332
357
  new_rect = [
@@ -379,72 +404,88 @@ def update_field_readonly(annot: DictionaryObject, val: bool) -> None:
379
404
  _update_field_flag(annot, READ_ONLY, val)
380
405
 
381
406
 
382
- def update_field_x(annot: DictionaryObject, val: float) -> None:
407
+ def update_field_x(annot: DictionaryObject, val: int | float) -> None:
383
408
  """
384
409
  Updates the X-coordinate (horizontal position) of a form field annotation.
385
410
 
386
411
  This function modifies the 'Rect' entry of the annotation dictionary to change
387
412
  the left X-coordinate of the field and adjusts the right X-coordinate by the
388
- same delta to maintain the field's width. Non-float values are ignored.
413
+ same delta to maintain the field's width. Integer and float inputs are
414
+ normalized to floats before updating the rectangle.
389
415
 
390
416
  Args:
391
417
  annot (DictionaryObject): The annotation dictionary for the form field.
392
- val (float): The new X-coordinate for the left edge of the field.
418
+ val (int | float): The new X-coordinate for the left edge of the field.
419
+
420
+ Raises:
421
+ TypeError: If the coordinate is not an integer or float, or is a boolean.
393
422
  """
394
- if isinstance(val, float):
395
- diff = val - annot[Rect][0]
396
- annot[NameObject(Rect)][0] = FloatObject(val)
397
- annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)
423
+ val = _normalize_geometry_value(val)
424
+ diff = val - annot[Rect][0]
425
+ annot[NameObject(Rect)][0] = FloatObject(val)
426
+ annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)
398
427
 
399
428
 
400
- def update_field_y(annot: DictionaryObject, val: float) -> None:
429
+ def update_field_y(annot: DictionaryObject, val: int | float) -> None:
401
430
  """
402
431
  Updates the Y-coordinate (vertical position) of a form field annotation.
403
432
 
404
433
  This function modifies the 'Rect' entry of the annotation dictionary to change
405
434
  the bottom Y-coordinate of the field and adjusts the top Y-coordinate by the
406
- same delta to maintain the field's height. Non-float values are ignored.
435
+ same delta to maintain the field's height. Integer and float inputs are
436
+ normalized to floats before updating the rectangle.
407
437
 
408
438
  Args:
409
439
  annot (DictionaryObject): The annotation dictionary for the form field.
410
- val (float): The new Y-coordinate for the bottom edge of the field.
440
+ val (int | float): The new Y-coordinate for the bottom edge of the field.
441
+
442
+ Raises:
443
+ TypeError: If the coordinate is not an integer or float, or is a boolean.
411
444
  """
412
- if isinstance(val, float):
413
- diff = val - annot[Rect][1]
414
- annot[NameObject(Rect)][1] = FloatObject(val)
415
- annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)
445
+ val = _normalize_geometry_value(val)
446
+ diff = val - annot[Rect][1]
447
+ annot[NameObject(Rect)][1] = FloatObject(val)
448
+ annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)
416
449
 
417
450
 
418
- def update_field_width(annot: DictionaryObject, val: float) -> None:
451
+ def update_field_width(annot: DictionaryObject, val: int | float) -> None:
419
452
  """
420
453
  Updates the width of a form field annotation.
421
454
 
422
455
  This function modifies the 'Rect' entry of the annotation dictionary to set
423
456
  the new width of the field, adjusting the rightmost coordinate while keeping
424
- the leftmost coordinate fixed. Non-float values are ignored.
457
+ the leftmost coordinate fixed. Integer and float inputs are normalized to
458
+ floats before updating the rectangle.
425
459
 
426
460
  Args:
427
461
  annot (DictionaryObject): The annotation dictionary for the form field.
428
- val (float): The new width of the field.
462
+ val (int | float): The new width of the field.
463
+
464
+ Raises:
465
+ TypeError: If the width is not an integer or float, or is a boolean.
429
466
  """
430
- if isinstance(val, float):
431
- annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)
467
+ val = _normalize_geometry_value(val)
468
+ annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)
432
469
 
433
470
 
434
- def update_field_height(annot: DictionaryObject, val: float) -> None:
471
+ def update_field_height(annot: DictionaryObject, val: int | float) -> None:
435
472
  """
436
473
  Updates the height of a form field annotation.
437
474
 
438
475
  This function modifies the 'Rect' entry of the annotation dictionary to set
439
476
  the new height of the field, adjusting the topmost coordinate while keeping
440
- the bottommost coordinate fixed. Non-float values are ignored.
477
+ the bottommost coordinate fixed. Integer and float inputs are normalized to
478
+ floats before updating the rectangle.
441
479
 
442
480
  Args:
443
481
  annot (DictionaryObject): The annotation dictionary for the form field.
444
- val (float): The new height of the field.
482
+ val (int | float): The new height of the field.
483
+
484
+ Raises:
485
+ TypeError: If the height is not an integer or float, or is a boolean.
445
486
  """
446
- if isinstance(val, float):
447
- annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)
487
+ val = _normalize_geometry_value(val)
488
+ annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)
448
489
 
449
490
 
450
491
  def update_field_tooltip(annot: DictionaryObject, val: str) -> None:
@@ -8,9 +8,29 @@ common attributes and methods for all form widgets, such as name, value,
8
8
  and schema definition.
9
9
  """
10
10
 
11
+ from dataclasses import dataclass
11
12
  from typing import Any, List, Optional, TextIO
12
13
 
13
14
 
15
+ @dataclass(frozen=True)
16
+ class PreparedValue:
17
+ """
18
+ Represents a widget value prepared for assignment.
19
+
20
+ Preparation validates and normalizes the value without changing the widget.
21
+ Dropdown updates can also include replacement choices, which are stored and
22
+ queued for PDF updates only when the prepared value is applied.
23
+
24
+ Attributes:
25
+ value (Any): The value to store in the widget.
26
+ choices (list | None): Replacement dropdown choices, or None when the
27
+ choices should remain unchanged.
28
+ """
29
+
30
+ value: Any
31
+ choices: list | None = None
32
+
33
+
14
34
  class Widget:
15
35
  """
16
36
  Base class for form widget.
@@ -136,7 +156,34 @@ class Widget:
136
156
  Args:
137
157
  value (Any): The value to set.
138
158
  """
139
- self._value = value
159
+ self.apply_value(self.prepare_value(value))
160
+
161
+ def prepare_value(self, value: Any) -> PreparedValue:
162
+ """
163
+ Prepares a value for assignment without changing the widget.
164
+
165
+ The base implementation retains the supplied value. Subclasses can
166
+ validate or normalize it and stage related attribute changes here.
167
+
168
+ Args:
169
+ value (Any): The proposed value of the widget.
170
+
171
+ Returns:
172
+ PreparedValue: The value to apply after preparation succeeds.
173
+ """
174
+ return PreparedValue(value)
175
+
176
+ def apply_value(self, update: PreparedValue) -> None:
177
+ """
178
+ Applies a previously prepared value to the widget.
179
+
180
+ This method stores the value without repeating validation. Subclasses
181
+ can apply related attribute changes through their existing hooks.
182
+
183
+ Args:
184
+ update (PreparedValue): The prepared widget value to store.
185
+ """
186
+ self._value = update.value
140
187
 
141
188
  @property
142
189
  def schema_definition(self) -> dict:
@@ -76,7 +76,7 @@ class Checkbox(Widget):
76
76
  return {"type": "boolean", **super().schema_definition}
77
77
 
78
78
  @property
79
- def sample_value(self) -> bool | int:
79
+ def sample_value(self) -> bool | int | None:
80
80
  """
81
81
  Returns a sample value for the checkbox.
82
82
 
@@ -84,6 +84,6 @@ class Checkbox(Widget):
84
84
  checkbox field.
85
85
 
86
86
  Returns:
87
- bool | int: A sample value for the checkbox.
87
+ bool | int | None: A sample value for the checkbox or its subclasses.
88
88
  """
89
89
  return True
@@ -6,9 +6,9 @@ This module defines the Dropdown class, which is a subclass of the
6
6
  Widget class. It represents a dropdown form field in a PDF document.
7
7
  """
8
8
 
9
- from typing import Optional
9
+ from typing import Any, Optional
10
10
 
11
- from .base import Widget
11
+ from .base import PreparedValue, Widget
12
12
 
13
13
 
14
14
  class Dropdown(Widget):
@@ -18,7 +18,7 @@ class Dropdown(Widget):
18
18
  Inherits from the Widget class and provides specific functionality
19
19
  for handling dropdown form fields. The stored value is an option index; string
20
20
  assignments are normalized to an existing option index or appended as a new
21
- choice.
21
+ choice. Integer indices must identify an existing option.
22
22
 
23
23
  Key attributes:
24
24
  font (str): The font of the dropdown field.
@@ -81,21 +81,92 @@ class Dropdown(Widget):
81
81
 
82
82
  If the value is a string, it attempts to find the corresponding
83
83
  index in the choices list. If not found, the string value is
84
- added to the choices, and its new index is used. Integer values are stored
85
- directly as indices.
84
+ added to the choices, and its new index is used. Integer values are checked
85
+ against the current choices before being stored.
86
86
 
87
87
  Args:
88
88
  value (str | int): The value to set. Can be a string
89
89
  (option text) or an integer (index).
90
+
91
+ Raises:
92
+ ValueError: If an integer index is outside the current choices.
93
+ """
94
+ self.apply_value(self.prepare_value(value))
95
+
96
+ def prepare_value(self, value: Any) -> PreparedValue:
90
97
  """
98
+ Prepares a dropdown selection without changing its value or choices.
99
+
100
+ Integer indices are checked against the current choices. Strings are
101
+ normalized to an existing index or staged as a new last option. New
102
+ choices do not trigger hooks until the prepared selection is applied.
103
+
104
+ Args:
105
+ value (Any): The proposed option text or index.
106
+
107
+ Returns:
108
+ PreparedValue: The selected index and any replacement choices.
109
+
110
+ Raises:
111
+ ValueError: If an integer index is outside the current choices.
112
+ """
113
+ self.validate_choice_index(value)
91
114
  if isinstance(value, str):
92
115
  index = self._get_option_index(value)
93
116
  if index is None:
94
- self.choices = list(self.choices) + [value]
95
- index = len(self.choices) - 1
117
+ choices = list(self.choices) + [value]
118
+ return PreparedValue(len(choices) - 1, choices)
96
119
  value = index
97
120
 
98
- self._value = value
121
+ return super().prepare_value(value)
122
+
123
+ def apply_value(self, update: PreparedValue) -> None:
124
+ """
125
+ Applies a previously prepared dropdown selection.
126
+
127
+ Replacement choices are stored through the existing attribute setter
128
+ so their PDF update hook is queued before the selected index is stored.
129
+ Validation and option lookup are not repeated during application.
130
+
131
+ Args:
132
+ update (PreparedValue): The prepared selection and optional choices.
133
+ """
134
+ if update.choices is not None:
135
+ self.choices = update.choices
136
+ super().apply_value(update)
137
+
138
+ def choice_index_schema(self) -> dict:
139
+ """
140
+ Returns the schema definition for indices into the current choices.
141
+
142
+ Valid indices range from zero to the number of choices minus one. An
143
+ empty dropdown explicitly rejects every integer selection.
144
+
145
+ Returns:
146
+ dict: A dictionary representing the schema definition for indices.
147
+ """
148
+ number_of_options = len(self.choices or [])
149
+ if not number_of_options:
150
+ return {"type": "integer", "not": {}}
151
+ return {"type": "integer", "minimum": 0, "maximum": number_of_options - 1}
152
+
153
+ def validate_choice_index(self, value: object) -> None:
154
+ """
155
+ Validates an integer selection against the current dropdown choices.
156
+
157
+ Non-integer values retain their existing handling, including strings
158
+ normalized during preparation and None for an unset selection.
159
+
160
+ Args:
161
+ value (object): The proposed value of the dropdown.
162
+
163
+ Raises:
164
+ ValueError: If an integer index is outside the current choices.
165
+ """
166
+ number_of_options = len(self.choices or [])
167
+ if isinstance(value, int) and not 0 <= value < number_of_options:
168
+ msg = f"Choice index {value} must satisfy 0 <= index < {number_of_options}."
169
+ raise ValueError(msg)
99
170
 
100
171
  def _get_option_index(self, value: str) -> int | None:
101
172
  """
@@ -128,8 +199,7 @@ class Dropdown(Widget):
128
199
  return {
129
200
  "anyOf": [
130
201
  {
131
- "type": "integer",
132
- "maximum": len(self.choices) - 1,
202
+ **self.choice_index_schema(),
133
203
  **super().schema_definition,
134
204
  },
135
205
  {"type": "string"},
@@ -137,14 +207,15 @@ class Dropdown(Widget):
137
207
  }
138
208
 
139
209
  @property
140
- def sample_value(self) -> int:
210
+ def sample_value(self) -> int | None:
141
211
  """
142
212
  Returns a sample value for the dropdown.
143
213
 
144
214
  The sample value is used to generate example data for the
145
- dropdown field. It selects the last available option.
215
+ dropdown field. It selects the last available option, or returns None
216
+ when no choices are available so sample data can omit the field.
146
217
 
147
218
  Returns:
148
- int: A sample value for the dropdown.
219
+ int | None: The last option index, or None when there are no choices.
149
220
  """
150
- return len(self.choices) - 1
221
+ return len(self.choices) - 1 if self.choices else None
@@ -0,0 +1,129 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Module representing a radio button widget.
4
+
5
+ This module defines the Radio class, which is a subclass of the
6
+ Checkbox class. It represents a radio button form field in a PDF
7
+ document, allowing users to select one option from a group of choices.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from .base import PreparedValue
13
+ from .checkbox import Checkbox
14
+
15
+
16
+ class Radio(Checkbox):
17
+ """
18
+ Represents a radio button widget.
19
+
20
+ The Radio class provides a concrete implementation for radio button
21
+ form fields. It inherits from the Checkbox class and implements
22
+ the schema_definition and sample_value properties.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ name: str,
28
+ value: int = None,
29
+ ) -> None:
30
+ """
31
+ Initializes a radio button widget.
32
+
33
+ Args:
34
+ name (str): The name of the radio button.
35
+ value (int): The initial value of the radio button. Defaults to None.
36
+
37
+ Attributes:
38
+ number_of_options (int): The number of options for the radio button.
39
+ """
40
+ super().__init__(name, value)
41
+
42
+ self.number_of_options: int = 0
43
+
44
+ def prepare_value(self, value: Any) -> PreparedValue:
45
+ """
46
+ Prepares a radio selection without changing the current value.
47
+
48
+ Integer indices are checked against the current option count. The
49
+ inherited value setter and PDF filling use this same preparation step.
50
+
51
+ Args:
52
+ value (Any): The proposed index of the option to select.
53
+
54
+ Returns:
55
+ PreparedValue: The selection to apply after validation succeeds.
56
+
57
+ Raises:
58
+ ValueError: If an integer index is outside the current options.
59
+ """
60
+ self.validate_choice_index(value)
61
+ return super().prepare_value(value)
62
+
63
+ def choice_index_schema(self) -> dict:
64
+ """
65
+ Returns the schema definition for indices into the current radio group.
66
+
67
+ Valid indices range from zero to the option count minus one. A group
68
+ with no options explicitly rejects every integer selection.
69
+
70
+ Returns:
71
+ dict: A dictionary representing the schema definition for indices.
72
+ """
73
+ if self.number_of_options <= 0:
74
+ return {"type": "integer", "not": {}}
75
+ return {
76
+ "type": "integer",
77
+ "minimum": 0,
78
+ "maximum": self.number_of_options - 1,
79
+ }
80
+
81
+ def validate_choice_index(self, value: object) -> None:
82
+ """
83
+ Validates an integer selection against the current radio option count.
84
+
85
+ Non-integer values retain their existing handling, including None for
86
+ an unset selection. Broader value-type validation is left to callers.
87
+
88
+ Args:
89
+ value (object): The proposed value of the radio button group.
90
+
91
+ Raises:
92
+ ValueError: If an integer index is outside the current options.
93
+ """
94
+ if isinstance(value, int) and not 0 <= value < self.number_of_options:
95
+ msg = f"Choice index {value} must satisfy 0 <= index < {self.number_of_options}."
96
+ raise ValueError(msg)
97
+
98
+ @property
99
+ def schema_definition(self) -> dict:
100
+ """
101
+ Returns the schema definition for the radio button.
102
+
103
+ The schema definition is a dictionary that describes the
104
+ data type and other constraints for the radio button value,
105
+ which is expected to be an integer representing the index
106
+ of the selected option.
107
+
108
+ Returns:
109
+ dict: A dictionary representing the schema definition.
110
+ """
111
+ return {
112
+ **super().schema_definition,
113
+ **self.choice_index_schema(),
114
+ }
115
+
116
+ @property
117
+ def sample_value(self) -> int | None:
118
+ """
119
+ Returns a sample value for the radio button.
120
+
121
+ The sample value is used to generate example data for the
122
+ radio button field. It returns the index of the last option
123
+ in the group, or None when no options are available so sample data can
124
+ omit the field.
125
+
126
+ Returns:
127
+ int | None: The last option index, or None when there are no options.
128
+ """
129
+ return self.number_of_options - 1 if self.number_of_options > 0 else None
@@ -89,7 +89,7 @@ class Text(Widget):
89
89
  Args:
90
90
  value (str): The value to set.
91
91
  """
92
- self._value = value
92
+ self.apply_value(self.prepare_value(value))
93
93
 
94
94
  @property
95
95
  def schema_definition(self) -> dict: