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.
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PKG-INFO +1 -1
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/__init__.py +1 -1
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/create.py +5 -2
- pypdfform-6.1.1/PyPDFForm/lib/clone.py +643 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/constants.py +6 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/utils.py +64 -86
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/watermark.py +52 -96
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/wrapper.py +17 -16
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/PKG-INFO +1 -1
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/SOURCES.txt +1 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_functional.py +635 -2
- {pypdfform-6.1.0 → pypdfform-6.1.1}/LICENSE +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/common.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/entry.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/inspect.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/remove.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/root.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/create.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/schemas/update.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/cli/update.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/adapter.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/base.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/link.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/stamp.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/text.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/annotations/text_markup.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/assets/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/assets/blank.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/coordinate.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/deprecation.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/egress.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/filler.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/font.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/hooks.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/image.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/base.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/checkbox.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/dropdown.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/image.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/radio.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/signature.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/middleware/text.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/patterns.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/circle.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/ellipse.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/image.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/line.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/rect.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/raw/text.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/template.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/__init__.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/base.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/checkbox.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/dropdown.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/image.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/radio.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/signature.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm/lib/widgets/text.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/dependency_links.txt +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/entry_points.txt +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/requires.txt +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/PyPDFForm.egg-info/top_level.txt +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/README.md +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/pyproject.toml +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/setup.cfg +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_bulk_create_fields.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_create_widget.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_draw_elements.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_dropdown.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_extract_middleware_attributes.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_fill_max_length_text_field.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_font_related.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_generate_appearance_streams.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_js.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_need_appearances.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_paragraph.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_signature.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_use_full_widget_name.py +0 -0
- {pypdfform-6.1.0 → pypdfform-6.1.1}/tests/test_widget_attr_trigger.py +0 -0
|
@@ -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.
|
|
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
|
|
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"
|