PyPDFForm 6.1.0__tar.gz → 6.1.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 (85) hide show
  1. {pypdfform-6.1.0 → pypdfform-6.1.1}/PKG-INFO +1 -1
  2. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/__init__.py +1 -1
  3. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/create.py +5 -2
  4. pypdfform-6.1.1/PyPDFForm/lib/clone.py +643 -0
  5. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/constants.py +6 -0
  6. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/utils.py +64 -86
  7. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/watermark.py +52 -96
  8. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/wrapper.py +17 -16
  9. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/PKG-INFO +1 -1
  10. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/SOURCES.txt +1 -0
  11. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_functional.py +635 -2
  12. {pypdfform-6.1.0 → pypdfform-6.1.1}/LICENSE +0 -0
  13. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/__init__.py +0 -0
  14. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/common.py +0 -0
  15. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/entry.py +0 -0
  16. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/inspect.py +0 -0
  17. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/remove.py +0 -0
  18. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/root.py +0 -0
  19. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/__init__.py +0 -0
  20. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/create.py +0 -0
  21. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/update.py +0 -0
  22. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/update.py +0 -0
  23. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/__init__.py +0 -0
  24. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/adapter.py +0 -0
  25. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/__init__.py +0 -0
  26. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/base.py +0 -0
  27. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/link.py +0 -0
  28. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/stamp.py +0 -0
  29. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/text.py +0 -0
  30. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/text_markup.py +0 -0
  31. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/assets/__init__.py +0 -0
  32. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/assets/blank.py +0 -0
  33. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/coordinate.py +0 -0
  34. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/deprecation.py +0 -0
  35. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/egress.py +0 -0
  36. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/filler.py +0 -0
  37. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/font.py +0 -0
  38. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/hooks.py +0 -0
  39. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/image.py +0 -0
  40. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/__init__.py +0 -0
  41. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/base.py +0 -0
  42. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/checkbox.py +0 -0
  43. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/dropdown.py +0 -0
  44. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/image.py +0 -0
  45. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/radio.py +0 -0
  46. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/signature.py +0 -0
  47. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/text.py +0 -0
  48. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/patterns.py +0 -0
  49. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/__init__.py +0 -0
  50. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/circle.py +0 -0
  51. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/ellipse.py +0 -0
  52. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/image.py +0 -0
  53. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/line.py +0 -0
  54. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/rect.py +0 -0
  55. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/text.py +0 -0
  56. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/template.py +0 -0
  57. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/__init__.py +0 -0
  58. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/base.py +0 -0
  59. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/checkbox.py +0 -0
  60. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/dropdown.py +0 -0
  61. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/image.py +0 -0
  62. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/radio.py +0 -0
  63. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/signature.py +0 -0
  64. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/text.py +0 -0
  65. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/dependency_links.txt +0 -0
  66. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/entry_points.txt +0 -0
  67. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/requires.txt +0 -0
  68. {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/top_level.txt +0 -0
  69. {pypdfform-6.1.0 → pypdfform-6.1.1}/README.md +0 -0
  70. {pypdfform-6.1.0 → pypdfform-6.1.1}/pyproject.toml +0 -0
  71. {pypdfform-6.1.0 → pypdfform-6.1.1}/setup.cfg +0 -0
  72. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_bulk_create_fields.py +0 -0
  73. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_create_widget.py +0 -0
  74. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_draw_elements.py +0 -0
  75. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_dropdown.py +0 -0
  76. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_extract_middleware_attributes.py +0 -0
  77. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_fill_max_length_text_field.py +0 -0
  78. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_font_related.py +0 -0
  79. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_generate_appearance_streams.py +0 -0
  80. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_js.py +0 -0
  81. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_need_appearances.py +0 -0
  82. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_paragraph.py +0 -0
  83. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_signature.py +0 -0
  84. {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_use_full_widget_name.py +0 -0
  85. {pypdfform-6.1.0 → pypdfform-6.1.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: 6.1.0
3
+ Version: 6.1.1
4
4
  Summary: The Python library & CLI for PDF forms.
5
5
  Author: Jinge Li
6
6
  License-Expression: MIT
@@ -16,7 +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
- __version__ = "6.1.0"
19
+ __version__ = "6.1.1"
20
20
 
21
21
  from .lib.annotations import Annotations
22
22
  from .lib.assets.blank import BlankPage
@@ -126,8 +126,11 @@ 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. Each page is extracted separately, so links to other pages
130
- are removed.
129
+ output path. Each page is extracted separately, so links and other GoTo
130
+ actions to other pages are removed, and fields shared with other pages keep
131
+ only their widgets on each page. A `ValueError` from `PdfWrapper.pages`
132
+ for other references to another page's content is not converted, so the
133
+ command exits with an error before writing the output.
131
134
 
132
135
  Args:
133
136
  ctx (typer.Context): Typer context containing global `PdfWrapper`
@@ -0,0 +1,643 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Module for cloning page annotations without content from excluded pages.
4
+
5
+ It provides `ClonePlan`, which decides which objects reachable from the
6
+ annotations of selected source pages are cloned into a writer. References are
7
+ remapped to the destination pages, shared fields keep only their cloned
8
+ widgets, and references to excluded content are removed or rejected.
9
+ """
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any, Dict, Iterable, List, NoReturn, Optional, cast
13
+
14
+ from pypdf import PdfReader, PdfWriter
15
+ from pypdf.generic import (
16
+ ArrayObject,
17
+ DictionaryObject,
18
+ IndirectObject,
19
+ NameObject,
20
+ StreamObject,
21
+ )
22
+
23
+ from .constants import (
24
+ AP,
25
+ AS,
26
+ DV,
27
+ FT,
28
+ A,
29
+ AcroForm,
30
+ Annots,
31
+ Btn,
32
+ D,
33
+ Dest,
34
+ Fields,
35
+ GoTo,
36
+ Hide,
37
+ Kids,
38
+ Link,
39
+ N,
40
+ Off,
41
+ Opt,
42
+ P,
43
+ Parent,
44
+ R,
45
+ ResetForm,
46
+ Root,
47
+ S,
48
+ SubmitForm,
49
+ Subtype,
50
+ T,
51
+ V,
52
+ Widget,
53
+ )
54
+
55
+
56
+ class ClonePlan:
57
+ """
58
+ Keeps the objects cloned with the annotations of selected pages within those pages.
59
+
60
+ The plan is built from one source reader and edits that reader's objects in
61
+ memory before they are cloned, so the reader must not be used for anything
62
+ else afterwards. Every annotation listed by a selected page is cloned unless
63
+ `select` rejects it or it is a link whose explicit destination is not a
64
+ selected page. Before cloning:
65
+
66
+ * References to selected pages become their mapped references, and
67
+ references to the document catalog become the writer's catalog. An
68
+ annotation's own `/P` is first pointed at the page that lists it.
69
+ * Fields get new `/Kids` arrays without annotations that are not cloned
70
+ and fields whose widgets are all not cloned, so a field shared with other
71
+ pages keeps only the cloned part of its hierarchy. A button field's
72
+ `/Opt` and positional on-states are updated to match its remaining kids.
73
+ * GoTo actions whose explicit destination is an excluded page are removed,
74
+ together with any actions chained after them.
75
+ * Any other reference to an excluded page, the page tree, the AcroForm
76
+ dictionary, an annotation that is not cloned, or a field whose widgets are
77
+ all not cloned raises `ValueError` instead of copying excluded content.
78
+ This includes fields that ResetForm, SubmitForm, and Hide actions name by
79
+ fully qualified name.
80
+ """
81
+
82
+ _REMOVE = object()
83
+ _NAMED_TARGETS = {ResetForm: Fields, SubmitForm: Fields, Hide: T}
84
+
85
+ def __init__(
86
+ self,
87
+ reader: PdfReader,
88
+ writer: PdfWriter,
89
+ page_references: Dict[int, IndirectObject],
90
+ select: Optional[Callable[[DictionaryObject], bool]] = None,
91
+ ) -> None:
92
+ """
93
+ Plans which annotations of a source reader are cloned into a writer.
94
+
95
+ Args:
96
+ reader (PdfReader): The source reader, used only for this plan.
97
+ writer (PdfWriter): The writer the annotations are cloned into.
98
+ page_references (Dict[int, IndirectObject]): The references that
99
+ replace each selected page, keyed by the object numbers of the
100
+ source pages. Other pages are excluded.
101
+ select (Optional[Callable[[DictionaryObject], bool]]): Decides which
102
+ annotations of selected pages are cloned. Defaults to all.
103
+ """
104
+ super().__init__()
105
+ self._reader = reader
106
+ self._writer = writer
107
+ self._pages = set(page_references)
108
+ self._references = dict(page_references)
109
+ self._visited: Dict[int, Any] = {}
110
+
111
+ forbidden = set()
112
+ root = reader.trailer.raw_get(Root)
113
+ self._references[root.idnum] = cast(
114
+ IndirectObject, writer.root_object.indirect_reference
115
+ )
116
+ acroform = root.get_object().get(AcroForm)
117
+ if isinstance(acroform, IndirectObject):
118
+ forbidden.add(acroform.idnum)
119
+
120
+ self._excluded_pages = {
121
+ cast(IndirectObject, page.indirect_reference).idnum for page in reader.pages
122
+ } - self._pages
123
+ forbidden |= self._excluded_pages
124
+
125
+ self.annotations: List[List[tuple[int, Any]]] = []
126
+ kept = set()
127
+ dropped = set()
128
+ owned = set()
129
+ self._kept_fields: set[int] = set()
130
+ for page in reader.pages:
131
+ reference = cast(IndirectObject, page.indirect_reference)
132
+ forbidden |= self._get_ancestors(page)
133
+ selected = []
134
+ annots = page.get(Annots, ArrayObject()).get_object()
135
+ for index, annot in enumerate(annots):
136
+ if reference.idnum not in self._pages:
137
+ if isinstance(annot, IndirectObject):
138
+ dropped.add(annot.idnum)
139
+ continue
140
+ obj = annot.get_object()
141
+ keep = self._is_selected(obj, select)
142
+ if isinstance(annot, IndirectObject):
143
+ (kept if keep else dropped).add(annot.idnum)
144
+ if not keep:
145
+ continue
146
+ selected.append((index, annot))
147
+ if P in obj and id(obj) not in owned:
148
+ obj[NameObject(P)] = reference
149
+ owned.add(id(obj))
150
+ if obj.get(Subtype) == Widget:
151
+ self._kept_fields |= self._get_ancestors(obj)
152
+ self.annotations.append(selected)
153
+
154
+ self._dropped = dropped - kept
155
+ self._excluded_fields: Dict[int, bool] = {}
156
+ self._field_names: Optional[Dict[str, IndirectObject]] = None
157
+ self._original_kids: Dict[int, ArrayObject] = {}
158
+ self._forbidden = (forbidden | self._dropped) - set(self._references)
159
+
160
+ @staticmethod
161
+ def _get_destination_page(destination: Any) -> int | None:
162
+ """
163
+ Gets the object number of the page an explicit destination refers to.
164
+
165
+ Args:
166
+ destination (Any): A destination value, or a reference to one.
167
+
168
+ Returns:
169
+ int | None: The object number of the destination's first element when
170
+ it is an indirect reference, or None for named destinations and
171
+ other values.
172
+ """
173
+ if isinstance(destination, IndirectObject):
174
+ destination = destination.get_object()
175
+ if (
176
+ isinstance(destination, ArrayObject)
177
+ and destination
178
+ and isinstance(destination[0], IndirectObject)
179
+ ):
180
+ return destination[0].idnum
181
+ return None
182
+
183
+ @staticmethod
184
+ def _get_link_destination(annot: DictionaryObject) -> Any:
185
+ """
186
+ Gets the destination of a link annotation.
187
+
188
+ Args:
189
+ annot (DictionaryObject): The annotation dictionary.
190
+
191
+ Returns:
192
+ Any: The link's `/Dest`, or the `/D` of its GoTo action, or None for
193
+ other annotations and actions.
194
+ """
195
+ if annot.get(Subtype) != Link:
196
+ return None
197
+ if Dest in annot:
198
+ return annot.raw_get(Dest)
199
+
200
+ action = annot.get(A, DictionaryObject()).get_object()
201
+ if isinstance(action, DictionaryObject) and action.get(S) == GoTo:
202
+ return action.get(D)
203
+ return None
204
+
205
+ def _is_selected(
206
+ self,
207
+ annot: DictionaryObject,
208
+ select: Optional[Callable[[DictionaryObject], bool]],
209
+ ) -> bool:
210
+ """
211
+ Checks whether an annotation of a selected page is cloned.
212
+
213
+ Args:
214
+ annot (DictionaryObject): The annotation dictionary.
215
+ select (Optional[Callable[[DictionaryObject], bool]]): The
216
+ annotation filter, if any.
217
+
218
+ Returns:
219
+ bool: False if `select` rejects the annotation or it is a link whose
220
+ explicit destination is not a selected page, otherwise True.
221
+ """
222
+ target = self._get_destination_page(self._get_link_destination(annot))
223
+ return (select is None or select(annot)) and (
224
+ target is None or target in self._pages
225
+ )
226
+
227
+ @staticmethod
228
+ def _get_ancestors(obj: DictionaryObject) -> set[int]:
229
+ """
230
+ Collects the object numbers along the `/Parent` chain of an object.
231
+
232
+ Args:
233
+ obj (DictionaryObject): A page, page tree node, or field.
234
+
235
+ Returns:
236
+ set[int]: The object numbers of its ancestors.
237
+ """
238
+ result = set()
239
+ parent = obj.get(Parent)
240
+ while isinstance(parent, IndirectObject) and parent.idnum not in result:
241
+ result.add(parent.idnum)
242
+ node = parent.get_object()
243
+ parent = node.get(Parent) if isinstance(node, DictionaryObject) else None
244
+ return result
245
+
246
+ def _is_excluded_field(self, reference: IndirectObject) -> bool:
247
+ """
248
+ Checks whether a reference is a field whose widgets are all not cloned.
249
+
250
+ A field is excluded when its `/Kids` subtree contains an annotation that
251
+ is not cloned and the field is not an ancestor of a cloned widget.
252
+ Fields without widgets on any page are not excluded. Results are cached.
253
+
254
+ Args:
255
+ reference (IndirectObject): A reference to a source object.
256
+
257
+ Returns:
258
+ bool: True if the referenced object is an excluded field.
259
+ """
260
+ if reference.idnum in self._kept_fields:
261
+ return False
262
+ if reference.idnum not in self._excluded_fields:
263
+ result = False
264
+ stack = [reference]
265
+ seen = {reference.idnum}
266
+ while stack and not result:
267
+ node = stack.pop()
268
+ result = node.idnum in self._dropped
269
+ obj = node.get_object()
270
+ if isinstance(obj, DictionaryObject) and Kids in obj:
271
+ kids = [
272
+ kid
273
+ for kid in obj[Kids]
274
+ if isinstance(kid, IndirectObject) and kid.idnum not in seen
275
+ ]
276
+ seen.update(kid.idnum for kid in kids)
277
+ stack.extend(kids)
278
+ self._excluded_fields[reference.idnum] = result
279
+ return self._excluded_fields[reference.idnum]
280
+
281
+ def _is_excluded(self, value: Any) -> bool:
282
+ """
283
+ Checks whether a value refers to content that is not cloned.
284
+
285
+ Args:
286
+ value (Any): A raw value from a source object, or None.
287
+
288
+ Returns:
289
+ bool: True if the value is a source reference to an excluded page,
290
+ the page tree, the AcroForm dictionary, an annotation that is
291
+ not cloned, or a field whose widgets are all not cloned.
292
+ """
293
+ return (
294
+ isinstance(value, IndirectObject)
295
+ and value.pdf is self._reader
296
+ and value.idnum not in self._references
297
+ and (value.idnum in self._forbidden or self._is_excluded_field(value))
298
+ )
299
+
300
+ @staticmethod
301
+ def _reject(path: str, page_number: int) -> NoReturn:
302
+ """
303
+ Raises the error for a reference to content that is not cloned.
304
+
305
+ Args:
306
+ path (str): The location of the reference.
307
+ page_number (int): The 1-based source page being guarded.
308
+
309
+ Raises:
310
+ ValueError: Always.
311
+ """
312
+ msg = (
313
+ f"Cannot copy page {page_number}: {path} refers to content that is "
314
+ "not being copied, such as another page or a field or annotation on "
315
+ "one."
316
+ )
317
+ raise ValueError(msg)
318
+
319
+ def _find_field(self, name: str) -> IndirectObject | None:
320
+ """
321
+ Finds a source field by its fully qualified name.
322
+
323
+ The field hierarchy under the AcroForm `/Fields` array is indexed on
324
+ first use, including kids that were already pruned.
325
+
326
+ Args:
327
+ name (str): The fully qualified field name.
328
+
329
+ Returns:
330
+ IndirectObject | None: A reference to the field with that name, or
331
+ None if there is none.
332
+ """
333
+ if self._field_names is None:
334
+ self._field_names = {}
335
+ acroform = (
336
+ self._reader.trailer[Root]
337
+ .get(AcroForm, DictionaryObject())
338
+ .get_object()
339
+ )
340
+ stack = [
341
+ (field, "")
342
+ for field in acroform.get(Fields, ArrayObject()).get_object()
343
+ if isinstance(field, IndirectObject)
344
+ ]
345
+ seen = {field.idnum for field, _ in stack}
346
+ while stack:
347
+ reference, prefix = stack.pop()
348
+ node = cast(DictionaryObject, reference.get_object())
349
+ if T in node:
350
+ prefix = f"{prefix}.{node[T]}" if prefix else str(node[T])
351
+ self._field_names.setdefault(prefix, reference)
352
+ kids = [
353
+ kid
354
+ for kid in self._original_kids.get(
355
+ id(node),
356
+ cast(ArrayObject, node.get(Kids, ArrayObject()).get_object()),
357
+ )
358
+ if isinstance(kid, IndirectObject) and kid.idnum not in seen
359
+ ]
360
+ seen.update(kid.idnum for kid in kids)
361
+ stack.extend((kid, prefix) for kid in kids)
362
+ return self._field_names.get(name)
363
+
364
+ def _check_named_targets(
365
+ self, action: DictionaryObject, path: str, page_number: int
366
+ ) -> None:
367
+ """
368
+ Rejects an action that names a field that is not cloned.
369
+
370
+ ResetForm and SubmitForm actions can name fields in `/Fields`, and Hide
371
+ actions in `/T`, by fully qualified name instead of by reference. Names
372
+ are resolved against the source field hierarchy, including names
373
+ stored as indirect strings, and follow the same rule as references.
374
+ Names of unknown fields are kept.
375
+
376
+ Args:
377
+ action (DictionaryObject): A source dictionary, possibly an action.
378
+ path (str): The dictionary's location, for error messages.
379
+ page_number (int): The 1-based source page being guarded.
380
+
381
+ Raises:
382
+ ValueError: If the action names a field that is not cloned.
383
+ """
384
+ key = self._NAMED_TARGETS.get(action.get(S))
385
+ if key is None or key not in action:
386
+ return
387
+
388
+ targets = action[key]
389
+ entries = (
390
+ [(f"{path}{key}[{index}]", target) for index, target in enumerate(targets)]
391
+ if isinstance(targets, ArrayObject)
392
+ else [(f"{path}{key}", targets)]
393
+ )
394
+ for entry_path, target in entries:
395
+ name = target.get_object()
396
+ if isinstance(name, str) and self._is_excluded(self._find_field(name)):
397
+ self._reject(entry_path, page_number)
398
+
399
+ def _prune_kids(self, field: DictionaryObject) -> None:
400
+ """
401
+ Removes kids that are not cloned from a field's `/Kids`.
402
+
403
+ The field gets a new `/Kids` array, so the original array stays intact
404
+ for anything else that refers to it, such as an action's `/Fields`,
405
+ and is still checked there. For a button field whose kids are widgets and whose `/Opt`, inherited
406
+ or not, has one entry per kid, the field gets its own `/Opt` with the
407
+ entries of the remaining kids, and their positional states are
408
+ renumbered with `_renumber_states`.
409
+
410
+ Args:
411
+ field (DictionaryObject): A source dictionary with `/Kids`.
412
+ """
413
+ kids = field[Kids]
414
+ keep = [index for index, kid in enumerate(kids) if not self._is_excluded(kid)]
415
+ if len(keep) == len(kids):
416
+ return
417
+
418
+ options = field.get_inherited(Opt, ArrayObject())
419
+ if (
420
+ field.get_inherited(FT) == Btn
421
+ and len(options) == len(kids)
422
+ and all(kid.get_object().get(Subtype) == Widget for kid in kids)
423
+ ):
424
+ self._renumber_states(
425
+ field, [kids[index].get_object() for index in keep], keep
426
+ )
427
+ field[NameObject(Opt)] = ArrayObject(options[index] for index in keep)
428
+ self._original_kids[id(field)] = kids
429
+ field[NameObject(Kids)] = ArrayObject(kids[index] for index in keep)
430
+
431
+ @staticmethod
432
+ def _renumber_states(
433
+ field: DictionaryObject, kids: List[DictionaryObject], keep: List[int]
434
+ ) -> None:
435
+ """
436
+ Renumbers positional states after a button field's `/Kids` are pruned.
437
+
438
+ With `/Opt`, a kid's on-state may be its position in `/Kids`, such as
439
+ `/0` or `/1`. Those names are renumbered to the new positions in each
440
+ remaining kid's `/AS` and normal, rollover, and down appearance
441
+ dictionaries. Each remaining kid gets its own `/AP` dictionary with
442
+ renamed copies of those state dictionaries, so dictionaries shared with
443
+ other fields are not changed. Appearance streams stay shared, and kids
444
+ or appearance types of this field that shared a state dictionary share
445
+ its copy (see `_rename_appearances`). The copies hold the same values as
446
+ the originals, which the guard walks. The field's `/V` and `/DV`, inherited or
447
+ not, are renumbered on the field itself. One naming the position of a
448
+ removed kid is deleted from the field, or set to `/Off` when an
449
+ ancestor would otherwise still supply a value. Other state names are
450
+ kept.
451
+
452
+ Args:
453
+ field (DictionaryObject): The button field.
454
+ kids (List[DictionaryObject]): The remaining kids, in order.
455
+ keep (List[int]): The original positions of the remaining kids.
456
+ """
457
+ states = {
458
+ NameObject(f"/{old}"): NameObject(f"/{new}") for new, old in enumerate(keep)
459
+ }
460
+ for key in (V, DV):
461
+ value = field.get_inherited(key)
462
+ if not (isinstance(value, NameObject) and value[1:].isdigit()):
463
+ continue
464
+ if value in states:
465
+ field[NameObject(key)] = states[value]
466
+ continue
467
+ if key in field:
468
+ del field[key]
469
+ if field.get_inherited(key) is not None:
470
+ field[NameObject(key)] = NameObject(Off)
471
+
472
+ copies: Dict[int, tuple[DictionaryObject, DictionaryObject]] = {}
473
+ for kid in kids:
474
+ if AS in kid and kid[AS] in states:
475
+ kid[NameObject(AS)] = states[kid[AS]]
476
+ if AP in kid:
477
+ kid[NameObject(AP)] = ClonePlan._rename_appearances(
478
+ cast(DictionaryObject, kid[AP]), states, copies
479
+ )
480
+
481
+ @staticmethod
482
+ def _rename_appearances(
483
+ appearances: DictionaryObject,
484
+ states: Dict[NameObject, NameObject],
485
+ copies: Dict[int, tuple[DictionaryObject, DictionaryObject]],
486
+ ) -> DictionaryObject:
487
+ """
488
+ Copies a kid's appearance dictionary with renamed state dictionaries.
489
+
490
+ The normal, rollover, and down entries that are state dictionaries are
491
+ replaced with copies whose keys are renamed by `states`. A state
492
+ dictionary already copied for another kid or entry of the same field
493
+ reuses that copy. Appearance streams, including entries that are a
494
+ single stream, are kept as they are.
495
+
496
+ Args:
497
+ appearances (DictionaryObject): The kid's `/AP` dictionary.
498
+ states (Dict[NameObject, NameObject]): The renamed states.
499
+ copies (Dict[int, tuple[DictionaryObject, DictionaryObject]]): The
500
+ state dictionaries copied so far for the field, with their
501
+ originals kept alive, keyed by the originals' `id`.
502
+
503
+ Returns:
504
+ DictionaryObject: The new `/AP` dictionary.
505
+ """
506
+ result = DictionaryObject(
507
+ {key: appearances.raw_get(key) for key in appearances}
508
+ )
509
+ for key in (N, R, D):
510
+ if key not in result:
511
+ continue
512
+ state = result[key]
513
+ if not isinstance(state, DictionaryObject) or isinstance(
514
+ state, StreamObject
515
+ ):
516
+ continue
517
+ if id(state) not in copies:
518
+ entries = [(name, state.raw_get(name)) for name in state]
519
+ copies[id(state)] = (
520
+ state,
521
+ DictionaryObject(
522
+ {name: value for name, value in entries if name not in states}
523
+ | {
524
+ states[name]: value
525
+ for name, value in entries
526
+ if name in states
527
+ }
528
+ ),
529
+ )
530
+ result[NameObject(key)] = copies[id(state)][1]
531
+ return result
532
+
533
+ def _check(self, value: Any, path: str, page_number: int) -> Any:
534
+ """
535
+ Decides what happens to one entry of an object being guarded.
536
+
537
+ Args:
538
+ value (Any): The raw entry value.
539
+ path (str): The entry's location, for error messages.
540
+ page_number (int): The 1-based source page being guarded.
541
+
542
+ Returns:
543
+ Any: `_REMOVE` to remove the entry, a replacement reference, or None
544
+ to keep it.
545
+
546
+ Raises:
547
+ ValueError: If the entry refers to excluded content.
548
+ """
549
+ if (
550
+ isinstance(value, IndirectObject)
551
+ and value.pdf is self._reader
552
+ and value.idnum in self._references
553
+ ):
554
+ return self._references[value.idnum]
555
+ if self._is_excluded(value):
556
+ self._reject(path, page_number)
557
+
558
+ action = value.get_object()
559
+ if (
560
+ isinstance(action, DictionaryObject)
561
+ and action.get(S) == GoTo
562
+ and self._get_destination_page(action.get(D)) in self._excluded_pages
563
+ ):
564
+ return self._REMOVE
565
+ return None
566
+
567
+ def guard(
568
+ self,
569
+ root: DictionaryObject,
570
+ path: str,
571
+ page_number: int,
572
+ skip: Iterable[str] = (),
573
+ ) -> None:
574
+ """
575
+ Applies the plan to every source object reachable from a root object.
576
+
577
+ Args:
578
+ root (DictionaryObject): The page or annotation to start from.
579
+ path (str): The root's location, for error messages.
580
+ page_number (int): The 1-based source page of the root.
581
+ skip (Iterable[str]): Keys of the root that are not followed.
582
+
583
+ Raises:
584
+ ValueError: If a reachable object refers to excluded content.
585
+ """
586
+ stack: List[tuple[Any, str]] = [(root, path)]
587
+ while stack:
588
+ obj, path = stack.pop()
589
+ if id(obj) in self._visited:
590
+ continue
591
+ self._visited[id(obj)] = obj
592
+
593
+ is_dict = isinstance(obj, DictionaryObject)
594
+ if is_dict:
595
+ if Kids in obj:
596
+ self._prune_kids(obj)
597
+ self._check_named_targets(obj, path, page_number)
598
+ entries = (
599
+ [(key, obj.raw_get(key)) for key in list(obj)]
600
+ if is_dict
601
+ else list(enumerate(obj))
602
+ )
603
+ kept = []
604
+ for key, value in entries:
605
+ if is_dict and obj is root and key in skip:
606
+ continue
607
+ entry_path = f"{path}{key}" if is_dict else f"{path}[{key}]"
608
+ result = self._check(value, entry_path, page_number)
609
+ if result is self._REMOVE:
610
+ if is_dict:
611
+ del obj[key]
612
+ continue
613
+ if result is not None:
614
+ value = result
615
+ if is_dict:
616
+ obj[key] = value
617
+ elif isinstance(
618
+ target := value.get_object(), (DictionaryObject, ArrayObject)
619
+ ):
620
+ stack.append((target, entry_path))
621
+ kept.append(value)
622
+
623
+ if not is_dict:
624
+ obj[:] = kept
625
+
626
+ def clone_annotations(self) -> List[List[Any]]:
627
+ """
628
+ Guards and clones the selected annotations of each source page.
629
+
630
+ Returns:
631
+ List[List[Any]]: The cloned annotations, one list per source page.
632
+
633
+ Raises:
634
+ ValueError: If a selected annotation refers to excluded content.
635
+ """
636
+ for page_number, selected in enumerate(self.annotations, 1):
637
+ for index, annot in selected:
638
+ self.guard(annot.get_object(), f"{Annots}[{index}]", page_number)
639
+
640
+ return [
641
+ [annot.clone(self._writer) for _, annot in selected]
642
+ for selected in self.annotations
643
+ ]
@@ -61,8 +61,11 @@ REMOVED_KWARGS = {
61
61
  }
62
62
 
63
63
  Title = "/Title"
64
+ Root = "/Root"
64
65
  Annots = "/Annots"
65
66
  Annot = "/Annot"
67
+ P = "/P"
68
+ B = "/B"
66
69
  Link = "/Link"
67
70
  Dest = "/Dest"
68
71
  Contents = "/Contents"
@@ -111,6 +114,9 @@ AA = "/AA"
111
114
  Action = "/Action"
112
115
  S = "/S"
113
116
  GoTo = "/GoTo"
117
+ ResetForm = "/ResetForm"
118
+ SubmitForm = "/SubmitForm"
119
+ Hide = "/Hide"
114
120
  JavaScript = "/JavaScript"
115
121
  JS = "/JS"
116
122
  OpenAction = "/OpenAction"