Skip to content

Commit afbee38

Browse files
authored
ENH: Add reattach_fields function (#2480)
Parse page/document annotations for orphan fields and reattach them to AcroForm/Fields Closes #2453
1 parent 1b1ee6d commit afbee38

File tree

3 files changed

+116
-4
lines changed

3 files changed

+116
-4
lines changed

docs/user/forms.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,35 @@ parameter is `True` by default for legacy compatibility, but this flags the PDF
4242
Viewer to recompute the field's rendering, and may trigger a "save changes"
4343
dialog for users who open the generated PDF.
4444

45-
## A note about form fields and annotations
45+
## Some notes about form fields and annotations
4646

47-
The PDF form stores form fields as annotations with the subtype "\Widget". This means that the following two blocks of code will give fairly similar results:
47+
PDF forms have a dual-nature approach about the fields:
48+
49+
* Within the root object, an `/AcroForm` structure exists.
50+
Inside it you could find (optional):
51+
52+
- some global elements (Fonts, Resources,...)
53+
- some global flags (like `/NeedAppearances` (set/cleared with `auto_regenerate` parameter in `update_form_field_values()`) that indicates if the reading program should re-render the visual fields upon document launch)
54+
- `/XFA` that houses a form in XDP format (very specific XML that describes the form rendered by some viewers); the `/XFA` form overrides the page content
55+
- `/Fields` that houses an array of indirect references that reference the upper _Field_ Objects (roots)
56+
57+
* Within the page `/Annots`, you will spot `/Widget` annotations that define the visual rendering.
58+
59+
To flesh out this overview:
60+
61+
* The core specific properties of a field are:
62+
- `/FT`: Field Type (Button, Text, Choice, Signatures)
63+
- `/T`: Partial Field Name (see PDF Reference for more details)
64+
- `/V`: Field Value
65+
- `/DV` : Default Field Value (used when resetting a form for example)
66+
* In order to streamline readability, _Field_ Objects and _Widget_ Objects can be fused housing all properties.
67+
* Fields can be organised hierarchically, id est one field can be placed under another. In such instances, the `/Parent` will have an IndirectObject providing Bottom-Up links and `/Childs` is an array carrying IndirectObjects for Top-Down navigation; _Widget_ Objects are still required for visual rendering. To call upon them, use the *fully qualified field name* (where all the individual names of the parent objects are seperated by `.`)
68+
69+
For instance take two (visual) fields both called _city_, but attached below _sender_ and _receiver_; the corresponding full names will be _sender.city_ and _receiver.city_.
70+
* When a field is repeated on multiple pages, the Field Object will have many _Widget_ Objects in `/Childs`. These objects are pure _widgets_, containing no _field_ specific data.
71+
* If Fields stores only hidden values, no _Widgets_ are required.
72+
73+
In _pypdf_ fields are extracted from the `/Fields` array:
4874

4975
```python
5076
from pypdf import PdfReader
@@ -66,6 +92,10 @@ for page in reader.pages:
6692
fields.append(annot)
6793
```
6894

69-
However, while similar, there are some very important differences between the two above blocks of code. Most importantly, the first block will return a list of Field objects, where as the second will return more generic dictionary-like objects. The objects lists will *mostly* reference the same object in the underlying PDF, meaning you'll find that `obj_taken_fom_first_list.indirect_reference == obj_taken_from _second_list.indirect_reference`. Field objects are generally more ergonomic, as the exposed data can be access via clearly named properties. However, the more generic dictionary-like objects will contain data that the Field object does not expose, such as the Rect (the widget's position on the page). So, which to use will depend on your use case.
95+
However, while similar, there are some very important differences between the two above blocks of code. Most importantly, the first block will return a list of Field objects, whereas the second will return more generic dictionary-like objects. The objects lists will *mostly* reference the same object in the underlying PDF, meaning you'll find that `obj_taken_fom_first_list.indirect_reference == obj_taken_from _second_list.indirect_reference`. Field objects are generally more ergonomic, as the exposed data can be accessed via clearly named properties. However, the more generic dictionary-like objects will contain data that the Field object does not expose, such as the Rect (the widget's position on the page). Therefore the correct approach depends on your use case.
96+
97+
However, it's also important to note that the two lists do not *always* refer to the same underlying PDF object. For example, if the form contains radio buttons, you will find that `reader.get_fields()` will get the parent object (the group of radio buttons) whereas `page.annotations` will return all the child objects (the individual radio buttons).
98+
99+
__Caution: Remember that fields are not stored in pages: If you use `add_page()` the field structure is not copied. It is recommended to use `.append()` with the proper parameters instead.__
70100

71-
However, it's also important to note that the two lists do not *always* refer to the same underlying PDF objects. For example, if the form contains radio buttons, you will find that `reader.get_fields()` will get the parent object (the group of radio buttons) whereas `page.annotations` will return all the child objects (the individual radio buttons).
101+
In case of missing _field_ objects in `/Fields`, `writer.reattach_fields()` will parse page(s) annotations and will reattach them. This fix can not guess intermediate fields and will not report fields using the same _name_.

pypdf/_writer.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,55 @@ def update_page_form_field_values(
932932
value if value in k[AA.AP]["/N"] else "/Off"
933933
)
934934

935+
def reattach_fields(
936+
self, page: Optional[PageObject] = None
937+
) -> List[DictionaryObject]:
938+
"""
939+
Parse annotations within the page looking for orphan fields and
940+
reattach then into the Fields Structure
941+
942+
Args:
943+
page: page to analyze.
944+
If none is provided, all pages will be analyzed
945+
Returns:
946+
list of reattached fields
947+
"""
948+
lst = []
949+
if page is None:
950+
for p in self.pages:
951+
lst += self.reattach_fields(p)
952+
return lst
953+
954+
try:
955+
af = cast(DictionaryObject, self._root_object[CatalogDictionary.ACRO_FORM])
956+
except KeyError:
957+
af = DictionaryObject()
958+
self._root_object[NameObject(CatalogDictionary.ACRO_FORM)] = af
959+
try:
960+
fields = cast(ArrayObject, af[InteractiveFormDictEntries.Fields])
961+
except KeyError:
962+
fields = ArrayObject()
963+
af[NameObject(InteractiveFormDictEntries.Fields)] = fields
964+
965+
if "/Annots" not in page:
966+
return lst
967+
annots = cast(ArrayObject, page["/Annots"])
968+
for idx in range(len(annots)):
969+
ano = annots[idx]
970+
indirect = isinstance(ano, IndirectObject)
971+
ano = cast(DictionaryObject, ano.get_object())
972+
if ano.get("/Subtype", "") == "/Widget" and "/FT" in ano:
973+
if (
974+
"indirect_reference" in ano.__dict__
975+
and ano.indirect_reference in fields
976+
):
977+
continue
978+
if not indirect:
979+
annots[idx] = self._add_object(ano)
980+
fields.append(ano.indirect_reference)
981+
lst.append(ano)
982+
return lst
983+
935984
def clone_reader_document_root(self, reader: PdfReader) -> None:
936985
"""
937986
Copy the reader document root to the writer and all sub elements,

tests/test_writer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,3 +1978,36 @@ def create_number_pdf(n) -> BytesIO:
19781978
for n, page in enumerate(reader.pages):
19791979
text = page.extract_text()
19801980
assert text == str(n)
1981+
1982+
1983+
@pytest.mark.enable_socket()
1984+
def test_reattach_fields():
1985+
"""
1986+
Test Reattach function
1987+
addressed in #2453
1988+
"""
1989+
url = "https://github.com/py-pdf/pypdf/files/14241368/ExampleForm.pdf"
1990+
name = "iss2453.pdf"
1991+
reader = PdfReader(BytesIO(get_data_from_url(url, name=name)))
1992+
writer = PdfWriter()
1993+
for p in reader.pages:
1994+
writer.add_page(p)
1995+
assert len(writer.reattach_fields()) == 15
1996+
assert len(writer.reattach_fields()) == 0 # nothing to append anymore
1997+
assert len(writer._root_object["/AcroForm"]["/Fields"]) == 15
1998+
writer = PdfWriter(clone_from=reader)
1999+
assert len(writer.reattach_fields()) == 7
2000+
writer.reattach_fields()
2001+
assert len(writer._root_object["/AcroForm"]["/Fields"]) == 15
2002+
2003+
writer = PdfWriter()
2004+
for p in reader.pages:
2005+
writer.add_page(p)
2006+
ano = writer.pages[0]["/Annots"][0].get_object()
2007+
del ano.indirect_reference
2008+
writer.pages[0]["/Annots"][0] = ano
2009+
assert isinstance(writer.pages[0]["/Annots"][0], DictionaryObject)
2010+
assert len(writer.reattach_fields(writer.pages[0])) == 6
2011+
assert isinstance(writer.pages[0]["/Annots"][0], IndirectObject)
2012+
del writer.pages[1]["/Annots"]
2013+
assert len(writer.reattach_fields(writer.pages[1])) == 0

0 commit comments

Comments
 (0)