PyPDFForm 5.2.4__tar.gz → 5.3.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-5.2.4 → pypdfform-5.3.0}/PKG-INFO +2 -1
  2. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/__init__.py +1 -1
  3. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/common.py +43 -25
  4. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/create.py +24 -15
  5. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/entry.py +1 -1
  6. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/root.py +10 -8
  7. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/update.py +21 -18
  8. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/egress.py +98 -12
  9. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/utils.py +32 -14
  10. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/wrapper.py +4 -2
  11. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/PKG-INFO +2 -1
  12. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/requires.txt +1 -0
  13. {pypdfform-5.2.4 → pypdfform-5.3.0}/pyproject.toml +5 -1
  14. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_functional.py +16 -4
  15. {pypdfform-5.2.4 → pypdfform-5.3.0}/LICENSE +0 -0
  16. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/__init__.py +0 -0
  17. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/inspect.py +0 -0
  18. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/remove.py +0 -0
  19. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/schemas/__init__.py +0 -0
  20. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/schemas/create.py +0 -0
  21. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/cli/schemas/update.py +0 -0
  22. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/__init__.py +0 -0
  23. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/adapter.py +0 -0
  24. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/__init__.py +0 -0
  25. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/base.py +0 -0
  26. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/link.py +0 -0
  27. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/stamp.py +0 -0
  28. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/text.py +0 -0
  29. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  30. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/assets/__init__.py +0 -0
  31. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/assets/bedrock.py +0 -0
  32. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/assets/blank.py +0 -0
  33. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/constants.py +0 -0
  34. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/coordinate.py +0 -0
  35. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/deprecation.py +0 -0
  36. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/filler.py +0 -0
  37. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/font.py +0 -0
  38. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/hooks.py +0 -0
  39. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/image.py +0 -0
  40. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/__init__.py +0 -0
  41. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/base.py +0 -0
  42. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/checkbox.py +0 -0
  43. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/dropdown.py +0 -0
  44. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/image.py +0 -0
  45. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/radio.py +0 -0
  46. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/signature.py +0 -0
  47. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/middleware/text.py +0 -0
  48. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/patterns.py +0 -0
  49. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/__init__.py +0 -0
  50. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/circle.py +0 -0
  51. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/ellipse.py +0 -0
  52. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/image.py +0 -0
  53. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/line.py +0 -0
  54. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/rect.py +0 -0
  55. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/raw/text.py +0 -0
  56. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/template.py +0 -0
  57. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/types.py +0 -0
  58. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/watermark.py +0 -0
  59. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/__init__.py +0 -0
  60. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/base.py +0 -0
  61. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  62. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  63. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/image.py +0 -0
  64. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/radio.py +0 -0
  65. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/signature.py +0 -0
  66. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm/lib/widgets/text.py +0 -0
  67. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/SOURCES.txt +0 -0
  68. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  69. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/entry_points.txt +0 -0
  70. {pypdfform-5.2.4 → pypdfform-5.3.0}/PyPDFForm.egg-info/top_level.txt +0 -0
  71. {pypdfform-5.2.4 → pypdfform-5.3.0}/README.md +0 -0
  72. {pypdfform-5.2.4 → pypdfform-5.3.0}/setup.cfg +0 -0
  73. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_bulk_create_fields.py +0 -0
  74. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_create_widget.py +0 -0
  75. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_draw_elements.py +0 -0
  76. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_dropdown.py +0 -0
  77. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_extract_middleware_attributes.py +0 -0
  78. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_fill_max_length_text_field.py +0 -0
  79. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_font_widths.py +0 -0
  80. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_generate_appearance_streams.py +0 -0
  81. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_js.py +0 -0
  82. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_need_appearances.py +0 -0
  83. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_paragraph.py +0 -0
  84. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_signature.py +0 -0
  85. {pypdfform-5.2.4 → pypdfform-5.3.0}/tests/test_use_full_widget_name.py +0 -0
  86. {pypdfform-5.2.4 → pypdfform-5.3.0}/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.2.4
3
+ Version: 5.3.0
4
4
  Summary: The Python library & CLI for PDF forms.
5
5
  Author: Jinge Li
6
6
  License-Expression: MIT
@@ -33,6 +33,7 @@ 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"
35
35
  Requires-Dist: jsonschema<5.0.0,>=4.26.0; extra == "cli"
36
+ Requires-Dist: PyYAML<7.0.0,>=6.0.3; extra == "cli"
36
37
  Provides-Extra: dev
37
38
  Requires-Dist: coverage<8.0.0,>=7.14.1; extra == "dev"
38
39
  Requires-Dist: mike<3.0.0,>=2.2.0; extra == "dev"
@@ -18,7 +18,7 @@ from Python code or from the command line.
18
18
 
19
19
  import logging
20
20
 
21
- __version__ = "5.2.4"
21
+ __version__ = "5.3.0"
22
22
 
23
23
  from .lib.annotations import Annotations
24
24
  from .lib.assets.blank import BlankPage
@@ -2,8 +2,8 @@
2
2
  """
3
3
  This module provides shared helpers for PyPDFForm CLI commands.
4
4
 
5
- It contains utilities for loading JSON command input, registering custom fonts
6
- once per command invocation, and converting grouped JSON element definitions
5
+ It contains utilities for loading structured command input, registering custom
6
+ fonts once per command invocation, and converting grouped element definitions
7
7
  into the objects expected by `PdfWrapper` methods.
8
8
  """
9
9
 
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Annotated, Any, NoReturn
13
13
 
14
14
  import typer
15
+ import yaml
15
16
  from jsonschema import ValidationError, validate
16
17
 
17
18
  from .. import PdfWrapper
@@ -62,15 +63,15 @@ FIELD_NAMES = Annotated[
62
63
  ]
63
64
 
64
65
 
65
- def json_file_option(help_text: str):
66
+ def data_file_option(help_text: str):
66
67
  """
67
- Creates the common validated JSON file option.
68
+ Creates the common validated structured data file option.
68
69
 
69
70
  Args:
70
71
  help_text (str): Help text to display for the option.
71
72
 
72
73
  Returns:
73
- typer.Option: A configured `--file` / `-f` option for JSON file input.
74
+ typer.Option: A configured `--file` / `-f` option for file input.
74
75
  """
75
76
  return typer.Option(
76
77
  "--file",
@@ -109,7 +110,7 @@ def cli_bad_parameter(
109
110
 
110
111
  def _validation_error_path(exc: ValidationError) -> str:
111
112
  """
112
- Builds a dotted JSON path for a validation error.
113
+ Builds a dotted input data path for a validation error.
113
114
 
114
115
  Args:
115
116
  exc (ValidationError): The JSON schema validation error.
@@ -120,28 +121,45 @@ def _validation_error_path(exc: ValidationError) -> str:
120
121
  return ".".join(str(each) for each in exc.absolute_path)
121
122
 
122
123
 
123
- def load_json_file(data: Path, schema: dict, param_hint: str) -> Any:
124
+ def _input_file_kind(data: Path) -> str:
124
125
  """
125
- Loads a JSON CLI input file and validates it against a schema.
126
+ Gets the structured data format to use for a CLI input file.
126
127
 
127
128
  Args:
128
- data (Path): JSON file path.
129
+ data (Path): Input file path.
130
+
131
+ Returns:
132
+ str: The upper-case format name for user-facing messages.
133
+ """
134
+ if data.suffix.lower() in {".yaml", ".yml"}:
135
+ return "YAML"
136
+
137
+ return "JSON"
138
+
139
+
140
+ def load_data_file(data: Path, schema: dict, param_hint: str) -> Any:
141
+ """
142
+ Loads a YAML or JSON CLI input file and validates it against a schema.
143
+
144
+ Args:
145
+ data (Path): YAML or JSON file path.
129
146
  schema (dict): JSON schema to validate against.
130
- param_hint (str): CLI parameter associated with the JSON file.
147
+ param_hint (str): CLI parameter associated with the input file.
131
148
 
132
149
  Returns:
133
- Any: Parsed and validated JSON input.
150
+ Any: Parsed and validated input data.
134
151
 
135
152
  Raises:
136
153
  typer.BadParameter: Raised when the file cannot be loaded or validation
137
154
  fails.
138
155
  """
156
+ input_kind = _input_file_kind(data)
139
157
  try:
140
158
  with open(data, "r", encoding="utf-8") as f:
141
- input_data = json.load(f)
142
- except (OSError, json.JSONDecodeError) as exc:
159
+ input_data = yaml.safe_load(f) if input_kind == "YAML" else json.load(f)
160
+ except (OSError, json.JSONDecodeError, yaml.YAMLError) as exc:
143
161
  cli_bad_parameter(
144
- f"Invalid JSON file: {exc}",
162
+ f"Invalid {input_kind} file: {exc}",
145
163
  param_hint=param_hint,
146
164
  cause=exc,
147
165
  )
@@ -152,7 +170,7 @@ def load_json_file(data: Path, schema: dict, param_hint: str) -> Any:
152
170
  error_path = _validation_error_path(exc)
153
171
  location = f" at {error_path}" if error_path else ""
154
172
  cli_bad_parameter(
155
- f"Invalid JSON file{location}: {exc.message}",
173
+ f"Invalid {input_kind} file{location}: {exc.message}",
156
174
  param_hint=param_hint,
157
175
  cause=exc,
158
176
  )
@@ -191,15 +209,15 @@ def handle_font_registration(
191
209
  """
192
210
  Registers a custom font referenced by CLI input.
193
211
 
194
- CLI JSON files may provide a file path in a `font` parameter. This helper
212
+ CLI input files may provide a file path in a `font` parameter. This helper
195
213
  registers each unique font path on the supplied `PdfWrapper` once, assigns
196
214
  it a generated internal font name, and mutates `params["font"]` to that
197
215
  registered name so downstream field or element constructors can use it.
198
216
 
199
217
  Args:
200
218
  obj (PdfWrapper): The wrapper for the PDF currently being modified.
201
- params (dict): The element or widget parameters loaded from JSON. This
202
- dictionary is mutated when it contains a `font` key.
219
+ params (dict): The element or widget parameters loaded from the input
220
+ file. This dictionary is mutated when it contains a `font` key.
203
221
  registered_font (dict): Mapping of source font paths to generated
204
222
  `PdfWrapper` font names for the current command invocation.
205
223
  """
@@ -222,9 +240,9 @@ def create_elements_from_file(
222
240
  output: Path | None = None,
223
241
  ) -> None:
224
242
  """
225
- Creates PDF elements from grouped JSON definitions.
243
+ Creates PDF elements from grouped file definitions.
226
244
 
227
- The input JSON is expected to group element definitions by type, such as
245
+ The input data is expected to group element definitions by type, such as
228
246
  `text`, `image`, or `highlight`. Each group key is resolved through
229
247
  `element_map`, each item is constructed after optional font registration,
230
248
  and the resulting objects are passed to `method_name` on `PdfWrapper`.
@@ -232,21 +250,21 @@ def create_elements_from_file(
232
250
 
233
251
  Args:
234
252
  pdf (Path): The path to the input PDF file.
235
- data (Path): The path to the JSON file containing grouped element
253
+ data (Path): The path to the input file containing grouped element
236
254
  definitions.
237
- element_map (dict): Mapping from JSON group names to element classes or
238
- callables used to construct each object.
255
+ element_map (dict): Mapping from input group names to element classes
256
+ or callables used to construct each object.
239
257
  schema (dict): JSON schema used to validate the grouped definitions.
240
258
  method_name (str): Name of the `PdfWrapper` method that accepts the
241
259
  constructed elements, such as `bulk_create_fields`, `draw`, or
242
260
  `annotate`.
243
261
  ctx (typer.Context): Typer context containing global wrapper options in
244
262
  `ctx.obj`.
245
- param_hint (str): CLI parameter associated with the JSON file.
263
+ param_hint (str): CLI parameter associated with the input file.
246
264
  output (Path, optional): Path where the modified PDF should be saved. If
247
265
  omitted, the input PDF is overwritten. Defaults to None.
248
266
  """
249
- input_data = load_json_file(data, schema, param_hint)
267
+ input_data = load_data_file(data, schema, param_hint)
250
268
 
251
269
  obj = PdfWrapper(str(pdf), **ctx.obj)
252
270
  ungrouped_input = []
@@ -4,8 +4,8 @@ This module defines CLI commands for creating PDF files and PDF content.
4
4
 
5
5
  It exposes the `create` command group for blank PDFs, extracted page ranges,
6
6
  merged PDFs, form fields, raw drawn elements, annotations, and coordinate grid
7
- views. Commands in this module translate command-line arguments or grouped JSON
8
- input into `PdfWrapper`, `BlankPage`, `Fields`, `RawElements`, and
7
+ views. Commands in this module translate command-line arguments or grouped
8
+ YAML/JSON input into `PdfWrapper`, `BlankPage`, `Fields`, `RawElements`, and
9
9
  `Annotations` operations.
10
10
  """
11
11
 
@@ -21,7 +21,7 @@ from .common import (
21
21
  REQUIRED_OUTPUT_PDF,
22
22
  cli_bad_parameter,
23
23
  create_elements_from_file,
24
- json_file_option,
24
+ data_file_option,
25
25
  )
26
26
  from .schemas.create import ANNOTATION_SCHEMA, FIELD_SCHEMA, RAW_SCHEMA
27
27
 
@@ -194,13 +194,15 @@ def merge(
194
194
  def field(
195
195
  ctx: typer.Context,
196
196
  pdf: INPUT_PDF,
197
- data: Annotated[Path, json_file_option("JSON file with form field definitions.")],
197
+ data: Annotated[
198
+ Path, data_file_option("YAML or JSON file with form field definitions.")
199
+ ],
198
200
  output: OPTIONAL_OUTPUT_PDF = None,
199
201
  ) -> None:
200
202
  """
201
- Add form fields described by grouped JSON definitions.
203
+ Add form fields described by grouped YAML or JSON definitions.
202
204
 
203
- The command maps JSON groups such as `text`, `check`, and `signature` to
205
+ The command maps input groups such as `text`, `check`, and `signature` to
204
206
  PyPDFForm field classes, validates the input file against the CLI field
205
207
  schema, creates the corresponding field objects, and calls
206
208
  `PdfWrapper.bulk_create_fields` before writing the modified PDF.
@@ -209,7 +211,8 @@ def field(
209
211
  ctx (typer.Context): Typer context containing global `PdfWrapper`
210
212
  options in `ctx.obj`.
211
213
  pdf (Path): Input PDF path.
212
- data (Path): JSON file containing grouped form field definitions.
214
+ data (Path): YAML or JSON file containing grouped form field
215
+ definitions.
213
216
  output (Path, optional): Output PDF path. If omitted, the input PDF is
214
217
  overwritten. Defaults to None.
215
218
  """
@@ -240,13 +243,15 @@ def field(
240
243
  def raw(
241
244
  ctx: typer.Context,
242
245
  pdf: INPUT_PDF,
243
- data: Annotated[Path, json_file_option("JSON file with raw element definitions.")],
246
+ data: Annotated[
247
+ Path, data_file_option("YAML or JSON file with raw element definitions.")
248
+ ],
244
249
  output: OPTIONAL_OUTPUT_PDF = None,
245
250
  ) -> None:
246
251
  """
247
- Draw raw elements described by grouped JSON definitions.
252
+ Draw raw elements described by grouped YAML or JSON definitions.
248
253
 
249
- The command maps JSON groups such as `text`, `image`, and `rectangle` to
254
+ The command maps input groups such as `text`, `image`, and `rectangle` to
250
255
  raw element classes, validates the input file against the CLI raw element
251
256
  schema, creates the corresponding drawable objects, and calls
252
257
  `PdfWrapper.draw` before writing the modified PDF.
@@ -255,7 +260,8 @@ def raw(
255
260
  ctx (typer.Context): Typer context containing global `PdfWrapper`
256
261
  options in `ctx.obj`.
257
262
  pdf (Path): Input PDF path.
258
- data (Path): JSON file containing grouped raw element definitions.
263
+ data (Path): YAML or JSON file containing grouped raw element
264
+ definitions.
259
265
  output (Path, optional): Output PDF path. If omitted, the input PDF is
260
266
  overwritten. Defaults to None.
261
267
  """
@@ -286,13 +292,15 @@ def raw(
286
292
  def annotation(
287
293
  ctx: typer.Context,
288
294
  pdf: INPUT_PDF,
289
- data: Annotated[Path, json_file_option("JSON file with annotation definitions.")],
295
+ data: Annotated[
296
+ Path, data_file_option("YAML or JSON file with annotation definitions.")
297
+ ],
290
298
  output: OPTIONAL_OUTPUT_PDF = None,
291
299
  ) -> None:
292
300
  """
293
- Add annotations described by grouped JSON definitions.
301
+ Add annotations described by grouped YAML or JSON definitions.
294
302
 
295
- The command maps JSON groups such as `text`, `link`, and `highlight` to
303
+ The command maps input groups such as `text`, `link`, and `highlight` to
296
304
  annotation classes, validates the input file against the CLI annotation
297
305
  schema, creates the corresponding annotation objects, and calls
298
306
  `PdfWrapper.annotate` before writing the modified PDF.
@@ -301,7 +309,8 @@ def annotation(
301
309
  ctx (typer.Context): Typer context containing global `PdfWrapper`
302
310
  options in `ctx.obj`.
303
311
  pdf (Path): Input PDF path.
304
- data (Path): JSON file containing grouped annotation definitions.
312
+ data (Path): YAML or JSON file containing grouped annotation
313
+ definitions.
305
314
  output (Path, optional): Output PDF path. If omitted, the input PDF is
306
315
  overwritten. Defaults to None.
307
316
  """
@@ -10,7 +10,7 @@ of an import traceback when those optional dependencies are absent.
10
10
  import importlib
11
11
  import sys
12
12
 
13
- CLI_DEPENDENCIES = {"jsonschema", "typer"}
13
+ CLI_DEPENDENCIES = {"jsonschema", "typer", "yaml"}
14
14
  CLI_INSTALL_HINT = 'pipx install "PyPDFForm[cli]"'
15
15
  CLI_ENV_INSTALL_HINT = "pip install 'PyPDFForm[cli]'"
16
16
 
@@ -8,7 +8,7 @@ commands. The root callback collects global flags in the Typer context so each
8
8
  subcommand can initialize `PdfWrapper` with consistent settings.
9
9
 
10
10
  Commands:
11
- - `fill`: Fill an existing PDF form from JSON data.
11
+ - `fill`: Fill an existing PDF form from YAML or JSON data.
12
12
  - `create`: Create PDFs, fields, annotations, raw elements, and grid views.
13
13
  - `inspect`: Print form metadata and field data as JSON.
14
14
  - `update`: Modify PDF metadata, field names, properties, geometry, and scripts.
@@ -21,7 +21,7 @@ from typing import Annotated
21
21
  import typer
22
22
 
23
23
  from .. import PdfWrapper, Widgets, __version__
24
- from .common import INPUT_PDF, OPTIONAL_OUTPUT_PDF, json_file_option, load_json_file
24
+ from .common import INPUT_PDF, OPTIONAL_OUTPUT_PDF, data_file_option, load_data_file
25
25
  from .create import create_cli
26
26
  from .inspect import inspect_cli
27
27
  from .remove import remove_cli
@@ -145,12 +145,14 @@ def main(
145
145
 
146
146
  @cli_app.command(
147
147
  no_args_is_help=True,
148
- help="Fill a PDF form with JSON data.",
148
+ help="Fill a PDF form with YAML or JSON data.",
149
149
  )
150
150
  def fill(
151
151
  ctx: typer.Context,
152
152
  pdf: INPUT_PDF,
153
- data: Annotated[Path, json_file_option("JSON file with form field values.")],
153
+ data: Annotated[
154
+ Path, data_file_option("YAML or JSON file with form field values.")
155
+ ],
154
156
  output: OPTIONAL_OUTPUT_PDF = None,
155
157
  flatten: Annotated[
156
158
  bool,
@@ -158,11 +160,11 @@ def fill(
158
160
  ] = None,
159
161
  ) -> None:
160
162
  """
161
- Fill an existing PDF form from a validated JSON file.
163
+ Fill an existing PDF form from a validated YAML or JSON file.
162
164
 
163
165
  The command loads the input PDF with the global options stored by the root
164
166
  callback, expands the generated schema so image and signature widgets can
165
- accept path objects, validates the JSON input, normalizes image and
167
+ accept path objects, validates the input file, normalizes image and
166
168
  signature values, and writes the filled PDF to the requested output path or
167
169
  back to the input file.
168
170
 
@@ -170,7 +172,7 @@ def fill(
170
172
  ctx (typer.Context): Typer context containing global `PdfWrapper`
171
173
  options in `ctx.obj`.
172
174
  pdf (Path): Input PDF form path.
173
- data (Path): JSON file containing form field values.
175
+ data (Path): YAML or JSON file containing form field values.
174
176
  output (Path, optional): Output PDF path. If omitted, the input PDF is
175
177
  overwritten. Defaults to None.
176
178
  flatten (bool, optional): Whether to flatten form fields after filling.
@@ -196,7 +198,7 @@ def fill(
196
198
  ]
197
199
  }
198
200
 
199
- input_data = load_json_file(data, schema, "--file")
201
+ input_data = load_data_file(data, schema, "--file")
200
202
  for k, each in obj.widgets.items():
201
203
  if (
202
204
  k in input_data
@@ -5,8 +5,8 @@ This module defines CLI commands for updating existing PDF files.
5
5
  It exposes the `update` command group for metadata changes, PDF version
6
6
  changes, field bounds edits, field renames, field property updates, and
7
7
  document-level JavaScript actions. Commands in this module load command-line or
8
- JSON input, apply the matching `PdfWrapper` operation, and write the modified
9
- PDF to either the requested output path or the original file.
8
+ YAML/JSON input, apply the matching `PdfWrapper` operation, and write the
9
+ modified PDF to either the requested output path or the original file.
10
10
  """
11
11
 
12
12
  from enum import Enum
@@ -22,10 +22,10 @@ from .common import (
22
22
  INPUT_PDF,
23
23
  OPTIONAL_OUTPUT_PDF,
24
24
  cli_bad_parameter,
25
+ data_file_option,
25
26
  get_widget,
26
27
  handle_font_registration,
27
- json_file_option,
28
- load_json_file,
28
+ load_data_file,
29
29
  )
30
30
  from .schemas.update import FIELD_SCHEMA, RENAME_SCHEMA
31
31
 
@@ -186,18 +186,20 @@ def bounds(
186
186
 
187
187
  @update_cli.command(
188
188
  no_args_is_help=True,
189
- help="Rename form fields from JSON.",
189
+ help="Rename form fields from YAML or JSON.",
190
190
  )
191
191
  def rename(
192
192
  ctx: typer.Context,
193
193
  pdf: INPUT_PDF,
194
- data: Annotated[Path, json_file_option("JSON file with form field renames.")],
194
+ data: Annotated[
195
+ Path, data_file_option("YAML or JSON file with form field renames.")
196
+ ],
195
197
  output: OPTIONAL_OUTPUT_PDF = None,
196
198
  ) -> None:
197
199
  """
198
- Rename form fields using a validated JSON mapping file.
200
+ Rename form fields using a validated YAML or JSON mapping file.
199
201
 
200
- The command rejects full widget name mode, validates the JSON file against
202
+ The command rejects full widget name mode, validates the input file against
201
203
  the rename schema, resolves each existing field, queues the requested key
202
204
  updates on the `PdfWrapper`, commits the rename operations, and writes the
203
205
  modified PDF to the requested output path or back to the input file.
@@ -206,13 +208,14 @@ def rename(
206
208
  ctx (typer.Context): Typer context containing global `PdfWrapper`
207
209
  options in `ctx.obj`.
208
210
  pdf (Path): Input PDF path.
209
- data (Path): JSON file containing form field rename definitions.
211
+ data (Path): YAML or JSON file containing form field rename
212
+ definitions.
210
213
  output (Path, optional): Output PDF path. If omitted, the input PDF is
211
214
  overwritten. Defaults to None.
212
215
 
213
216
  Raises:
214
217
  typer.BadParameter: Raised when full widget name mode is enabled, the
215
- JSON file is invalid, or a requested field does not exist.
218
+ input file is invalid, or a requested field does not exist.
216
219
  """
217
220
  if ctx.obj.get("use_full_widget_name"):
218
221
  cli_bad_parameter(
@@ -221,7 +224,7 @@ def rename(
221
224
  param_hint="--use-full-widget-name",
222
225
  )
223
226
 
224
- input_data = load_json_file(data, RENAME_SCHEMA, "--file")
227
+ input_data = load_data_file(data, RENAME_SCHEMA, "--file")
225
228
 
226
229
  obj = PdfWrapper(str(pdf), **ctx.obj)
227
230
  for item in input_data:
@@ -234,20 +237,20 @@ def rename(
234
237
 
235
238
  @update_cli.command(
236
239
  no_args_is_help=True,
237
- help="Update form field properties from JSON.",
240
+ help="Update form field properties from YAML or JSON.",
238
241
  )
239
242
  def field(
240
243
  ctx: typer.Context,
241
244
  pdf: INPUT_PDF,
242
245
  data: Annotated[
243
- Path, json_file_option("JSON file with form field property updates.")
246
+ Path, data_file_option("YAML or JSON file with form field property updates.")
244
247
  ],
245
248
  output: OPTIONAL_OUTPUT_PDF = None,
246
249
  ) -> None:
247
250
  """
248
- Update form field properties from a validated JSON file.
251
+ Update form field properties from a validated YAML or JSON file.
249
252
 
250
- The command validates the JSON input against the field update schema, loads
253
+ The command validates the input file against the field update schema, loads
251
254
  the PDF with the global CLI options stored in `ctx.obj`, resolves each
252
255
  field name, registers any referenced fonts once per invocation, assigns the
253
256
  requested widget properties, and writes the modified PDF to the requested
@@ -257,15 +260,15 @@ def field(
257
260
  ctx (typer.Context): Typer context containing global `PdfWrapper`
258
261
  options in `ctx.obj`.
259
262
  pdf (Path): Input PDF path.
260
- data (Path): JSON file containing form field property updates.
263
+ data (Path): YAML or JSON file containing form field property updates.
261
264
  output (Path, optional): Output PDF path. If omitted, the input PDF is
262
265
  overwritten. Defaults to None.
263
266
 
264
267
  Raises:
265
- typer.BadParameter: Raised when the JSON file is invalid or a requested
268
+ typer.BadParameter: Raised when the input file is invalid or a requested
266
269
  field does not exist.
267
270
  """
268
- input_data = load_json_file(data, FIELD_SCHEMA, "--file")
271
+ input_data = load_data_file(data, FIELD_SCHEMA, "--file")
269
272
 
270
273
  obj = PdfWrapper(str(pdf), **ctx.obj)
271
274
  registered_font = {}
@@ -28,6 +28,7 @@ from .constants import (
28
28
  Fields,
29
29
  JavaScript,
30
30
  OpenAction,
31
+ Parent,
31
32
  Root,
32
33
  S,
33
34
  Title,
@@ -140,10 +141,10 @@ def rebuild_acroform_fields(
140
141
 
141
142
  The existing `/Fields` array is replaced, creating an AcroForm dictionary
142
143
  when necessary. Each page annotation is resolved to a widget key, and only
143
- annotations whose keys are present in `widget_keys` are added to the new
144
- array. Page annotation arrays are left unchanged. When no matching page
145
- annotations are found, the original PDF stream is returned unchanged to
146
- avoid an unnecessary rewrite.
144
+ annotations whose keys are present in `widget_keys` contribute their
145
+ top-level field object to the new array. Page annotation arrays are left
146
+ unchanged. When no matching page annotations are found, the original PDF
147
+ stream is returned unchanged to avoid an unnecessary rewrite.
147
148
 
148
149
  Args:
149
150
  pdf (bytes): The PDF stream whose AcroForm fields should be rebuilt.
@@ -155,25 +156,110 @@ def rebuild_acroform_fields(
155
156
  bytes: The PDF stream with a rebuilt AcroForm `/Fields` array, or the
156
157
  original stream when there are no matching widgets to rebuild.
157
158
  """
159
+ if not widget_keys:
160
+ return pdf
161
+
158
162
  writer = PdfWriter(BytesIO(pdf))
159
163
  root = writer._root_object # type: ignore # noqa: SLF001 # # pylint: disable=W0212
160
164
 
161
- if AcroForm not in root:
162
- root[NameObject(AcroForm)] = DictionaryObject({})
163
- root[AcroForm][NameObject(Fields)] = ArrayObject([])
164
-
165
- needs_update = False
165
+ fields = ArrayObject([])
166
+ seen_fields = set()
166
167
  for page in writer.pages:
167
168
  for annot in page.get(Annots, []):
168
169
  key = get_widget_key(annot.get_object(), use_full_widget_name)
169
170
  if key in widget_keys:
170
- root[AcroForm][Fields].append(annot)
171
- needs_update = True
171
+ field_ref = _get_root_field_reference(writer, annot)
172
+ field_key = _field_reference_key(field_ref)
173
+ if field_key not in seen_fields:
174
+ fields.append(field_ref)
175
+ seen_fields.add(field_key)
172
176
 
173
- if not needs_update:
177
+ if not seen_fields:
174
178
  return pdf
175
179
 
180
+ if AcroForm not in root:
181
+ root[NameObject(AcroForm)] = DictionaryObject({})
182
+ root[AcroForm][NameObject(Fields)] = fields
183
+
176
184
  with BytesIO() as f:
177
185
  writer.write(f)
178
186
  f.seek(0)
179
187
  return f.read()
188
+
189
+
190
+ def _get_root_field_reference(writer: PdfWriter, annot):
191
+ """
192
+ Returns the top-level AcroForm field reference for an annotation.
193
+
194
+ Widget annotations can be leaf nodes under a parent field dictionary. The
195
+ catalog `/AcroForm/Fields` array must point at root fields rather than
196
+ child widgets, otherwise readers such as pypdf will not expose hierarchical
197
+ fields like radio groups via `get_fields`.
198
+
199
+ Args:
200
+ writer (PdfWriter): The PDF writer that owns the annotation objects.
201
+ annot: The widget annotation reference or object.
202
+
203
+ Returns:
204
+ IndirectObject: The top-level AcroForm field reference.
205
+ """
206
+ field = annot
207
+ field_object = field.get_object()
208
+ visited = set()
209
+
210
+ while Parent in field_object and id(field_object) not in visited:
211
+ visited.add(id(field_object))
212
+ field = field_object[Parent]
213
+ field_object = field.get_object()
214
+
215
+ return _ensure_indirect_reference(writer, field)
216
+
217
+
218
+ def _ensure_indirect_reference(writer: PdfWriter, field):
219
+ """
220
+ Returns an indirect reference for a field object.
221
+
222
+ pypdf expects entries in `/AcroForm/Fields` to have an indirect reference
223
+ when it builds field objects. Parent fields cloned from page annotations can
224
+ appear as direct dictionaries that still know their original indirect
225
+ reference, so prefer that before adding a new indirect object.
226
+
227
+ Args:
228
+ writer (PdfWriter): The PDF writer that will own newly added objects.
229
+ field: The field reference or direct field object to normalize.
230
+
231
+ Returns:
232
+ IndirectObject: An indirect reference to the field object.
233
+ """
234
+ field_object = field.get_object()
235
+ indirect_reference = getattr(field_object, "indirect_reference", None)
236
+ if indirect_reference is not None:
237
+ return indirect_reference
238
+
239
+ result = field
240
+ if not hasattr(field, "idnum"):
241
+ result = writer._add_object(field_object) # type: ignore[attr-defined] # noqa: SLF001 # pylint: disable=W0212
242
+
243
+ return result
244
+
245
+
246
+ def _field_reference_key(field_ref) -> tuple:
247
+ """
248
+ Returns a stable key for deduplicating root fields in one writer.
249
+
250
+ Args:
251
+ field_ref: The field reference to identify.
252
+
253
+ Returns:
254
+ tuple: A stable key for the field reference.
255
+ """
256
+ field_object = field_ref.get_object()
257
+ indirect_reference = getattr(field_object, "indirect_reference", field_ref)
258
+ idnum = getattr(indirect_reference, "idnum", None)
259
+ generation = getattr(indirect_reference, "generation", None)
260
+
261
+ result = (id(field_object),)
262
+ if idnum is not None:
263
+ result = idnum, generation
264
+
265
+ return result
@@ -354,16 +354,15 @@ def get_version(pdf: bytes) -> str | None:
354
354
  start with a known PDF version identifier.
355
355
  """
356
356
 
357
- result = None
358
- for each in VERSION_IDENTIFIERS:
359
- if pdf.startswith(each):
360
- result = each.replace(VERSION_IDENTIFIER_PREFIX, b"").decode()
361
- break
357
+ version_identifier_length = len(VERSION_IDENTIFIERS[0])
358
+ version_identifier = pdf[:version_identifier_length]
359
+ if version_identifier not in VERSION_IDENTIFIERS:
360
+ return None
362
361
 
363
- return result
362
+ return version_identifier[len(VERSION_IDENTIFIER_PREFIX) :].decode()
364
363
 
365
364
 
366
- def set_version(pdf: bytes, old: str, new: str) -> bytes:
365
+ def set_version(pdf: bytes, old: str | None, new: str | None) -> bytes:
367
366
  """
368
367
  Replaces the first PDF header version marker in a byte stream.
369
368
 
@@ -374,15 +373,34 @@ def set_version(pdf: bytes, old: str, new: str) -> bytes:
374
373
 
375
374
  Args:
376
375
  pdf (bytes): The PDF stream to update.
377
- old (str): The currently present PDF version string.
378
- new (str): The PDF version string to write into the header.
376
+ old (str | None): The currently present PDF version string. When
377
+ None, the header token after `%PDF-` is replaced if one exists.
378
+ new (str | None): The PDF version string to write into the header. When
379
+ None, the PDF stream is returned unchanged.
379
380
 
380
381
  Returns:
381
382
  bytes: The PDF stream with the first matching version marker replaced.
382
383
  """
383
384
 
384
- return pdf.replace(
385
- VERSION_IDENTIFIER_PREFIX + bytes(old, "utf-8"),
386
- VERSION_IDENTIFIER_PREFIX + bytes(new, "utf-8"),
387
- 1,
388
- )
385
+ if new is None or old == new:
386
+ return pdf
387
+
388
+ new_header = VERSION_IDENTIFIER_PREFIX + new.encode()
389
+
390
+ if old is None:
391
+ if not pdf.startswith(VERSION_IDENTIFIER_PREFIX):
392
+ return pdf
393
+
394
+ header_end = len(pdf)
395
+ for separator in (b"\r", b"\n"):
396
+ separator_index = pdf.find(separator, len(VERSION_IDENTIFIER_PREFIX))
397
+ if separator_index != -1:
398
+ header_end = min(header_end, separator_index)
399
+
400
+ return new_header + pdf[header_end:]
401
+
402
+ old_header = VERSION_IDENTIFIER_PREFIX + old.encode()
403
+ if pdf.startswith(old_header):
404
+ return new_header + pdf[len(old_header) :]
405
+
406
+ return pdf.replace(old_header, new_header, 1)
@@ -585,8 +585,10 @@ class PdfWrapper:
585
585
  PdfWrapper: The `PdfWrapper` object, allowing for method chaining.
586
586
  """
587
587
 
588
- if self.version:
589
- self._stream = set_version(self._read(), self.version, version)
588
+ current_stream = self._read()
589
+ updated_stream = set_version(current_stream, self.version, version)
590
+ if updated_stream != current_stream:
591
+ self._stream = updated_stream
590
592
  self._version = version
591
593
 
592
594
  return self
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPDFForm
3
- Version: 5.2.4
3
+ Version: 5.3.0
4
4
  Summary: The Python library & CLI for PDF forms.
5
5
  Author: Jinge Li
6
6
  License-Expression: MIT
@@ -33,6 +33,7 @@ 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"
35
35
  Requires-Dist: jsonschema<5.0.0,>=4.26.0; extra == "cli"
36
+ Requires-Dist: PyYAML<7.0.0,>=6.0.3; extra == "cli"
36
37
  Provides-Extra: dev
37
38
  Requires-Dist: coverage<8.0.0,>=7.14.1; extra == "dev"
38
39
  Requires-Dist: mike<3.0.0,>=2.2.0; extra == "dev"
@@ -8,6 +8,7 @@ reportlab<6.0.0,>=4.5.1
8
8
  [cli]
9
9
  typer<1.0.0,>=0.26.1
10
10
  jsonschema<5.0.0,>=4.26.0
11
+ PyYAML<7.0.0,>=6.0.3
11
12
 
12
13
  [dev]
13
14
  coverage<8.0.0,>=7.14.1
@@ -43,7 +43,11 @@ Documentation = "https://chinapandaman.github.io/PyPDFForm/"
43
43
 
44
44
  [project.optional-dependencies]
45
45
  # update `CLI_DEPENDENCIES` in `PyPDFForm/cli/entry.py` when changing cli dependencies
46
- cli = ["typer>=0.26.1,<1.0.0", "jsonschema>=4.26.0,<5.0.0"]
46
+ cli = [
47
+ "typer>=0.26.1,<1.0.0",
48
+ "jsonschema>=4.26.0,<5.0.0",
49
+ "PyYAML>=6.0.3,<7.0.0",
50
+ ]
47
51
  dev = [
48
52
  "coverage>=7.14.1,<8.0.0",
49
53
  "mike>=2.2.0,<3.0.0",
@@ -21,6 +21,7 @@ from PyPDFForm.lib.constants import (
21
21
  from PyPDFForm.lib.deprecation import deprecation_notice
22
22
  from PyPDFForm.lib.middleware.base import Widget
23
23
  from PyPDFForm.lib.template import get_widget_key, get_widgets_by_page
24
+ from PyPDFForm.lib.utils import get_version, set_version
24
25
 
25
26
 
26
27
  def test_deprecation_warning():
@@ -113,10 +114,11 @@ def test_write(template_stream, tmp_path):
113
114
 
114
115
  def test_write_io(template_stream):
115
116
  buff = BytesIO()
116
- PdfWrapper(template_stream).write(buff)
117
+ obj = PdfWrapper(template_stream)
118
+ obj.write(buff)
117
119
  buff.seek(0)
118
120
 
119
- assert buff.read()
121
+ assert PdfWrapper(buff.read()).widgets.keys() == obj.widgets.keys()
120
122
 
121
123
 
122
124
  def test_fill_flatten_then_unflatten(template_stream, pdf_samples, data_dict, request):
@@ -507,7 +509,14 @@ def test_version(pdf_samples):
507
509
 
508
510
  obj = PdfWrapper(os.path.join(pdf_samples, "versions", "unknown.pdf"))
509
511
  assert obj.version is None
510
- assert obj.read()
512
+ stream = obj.read()
513
+ assert stream.startswith(b"%PDF-2.1")
514
+ assert get_version(b"not a pdf") is None
515
+ assert set_version(stream, obj.version, None) == stream
516
+ assert set_version(b"not a pdf", None, "2.0") == b"not a pdf"
517
+ assert set_version(b"prefix %PDF-1.7\n", "1.7", "2.0") == b"prefix %PDF-2.0\n"
518
+ assert obj.change_version("2.0").version == "2.0"
519
+ assert obj.read().startswith(b"%PDF-2.0")
511
520
 
512
521
 
513
522
  @pytest.mark.requires_zlib_over_zlib_ng
@@ -855,7 +864,10 @@ def test_remove_fields_update_widgets(template_stream):
855
864
 
856
865
 
857
866
  def test_remove_fields_no_keys_specified(template_stream):
858
- assert PdfWrapper(template_stream).remove_fields([]).read()
867
+ assert (
868
+ PdfWrapper(template_stream).remove_fields([]).widgets.keys()
869
+ == PdfWrapper(template_stream).widgets.keys()
870
+ )
859
871
 
860
872
 
861
873
  def test_merge(template_stream):
File without changes
File without changes
File without changes
File without changes