Skip to content

Commit 9cc752c

Browse files
metsw24-maxAlnida khanam
authored andcommitted
guard missing /AP appearance state in button form fields
1 parent 2266ee8 commit 9cc752c

2 files changed

Lines changed: 64 additions & 6 deletions

File tree

pypdf/_doc_common.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ def convert_to_int(d: bytes, size: int) -> Union[int, tuple[Any, ...]]:
9696
return cast(int, struct.unpack(">Q", d)[0])
9797

9898

99+
def _normal_appearance(appearance: Any) -> Optional[DictionaryObject]:
100+
"""Return the /N normal-appearance sub-dictionary, or None when absent."""
101+
appearance = appearance.get_object()
102+
if not isinstance(appearance, DictionaryObject) or "/N" not in appearance:
103+
return None
104+
normal = appearance["/N"]
105+
return normal if isinstance(normal, DictionaryObject) else None
106+
107+
99108
class DocumentInformation(DictionaryObject):
100109
"""
101110
A class representing the basic document metadata provided in a PDF File.
@@ -629,17 +638,22 @@ def _build_field(
629638
retval[key][NameObject("/_States_")] = obj[NameObject(FA.Opt)]
630639
if obj.get(FA.FT, "") == "/Btn" and "/AP" in obj:
631640
# Checkbox
632-
retval[key][NameObject("/_States_")] = ArrayObject(
633-
list(obj["/AP"]["/N"].keys())
634-
)
635-
if "/Off" not in retval[key]["/_States_"]:
636-
retval[key][NameObject("/_States_")].append(NameObject("/Off"))
641+
normal = _normal_appearance(obj["/AP"])
642+
if normal is not None:
643+
retval[key][NameObject("/_States_")] = ArrayObject(
644+
list(normal.keys())
645+
)
646+
if "/Off" not in retval[key]["/_States_"]:
647+
retval[key][NameObject("/_States_")].append(NameObject("/Off"))
637648
elif obj.get(FA.FT, "") == "/Btn" and obj.get(FA.Ff, 0) & FA.FfBits.Radio != 0:
638649
states: list[str] = []
639650
retval[key][NameObject("/_States_")] = ArrayObject(states)
640651
for k in obj.get(FA.Kids, {}):
641652
k = k.get_object()
642-
for s in list(k["/AP"]["/N"].keys()):
653+
normal = _normal_appearance(k["/AP"]) if "/AP" in k else None
654+
if normal is None:
655+
continue
656+
for s in list(normal.keys()):
643657
if s not in states:
644658
states.append(s)
645659
retval[key][NameObject("/_States_")] = ArrayObject(states)

tests/test_doc_common.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,50 @@ def test_build_destination__short_array():
208208
assert dest["/Type"] == "/FitR"
209209

210210

211+
def _reader_with_button_field(field: DictionaryObject) -> PdfReader:
212+
writer = PdfWriter()
213+
writer.add_blank_page(width=72, height=72)
214+
reference = writer._add_object(field)
215+
acroform = DictionaryObject()
216+
acroform[NameObject("/Fields")] = ArrayObject([reference])
217+
writer._root_object[NameObject("/AcroForm")] = writer._add_object(acroform)
218+
stream = BytesIO()
219+
writer.write(stream)
220+
stream.seek(0)
221+
return PdfReader(stream)
222+
223+
224+
def test_build_field__checkbox_missing_normal_appearance():
225+
# A checkbox whose /AP has no /N sub-dictionary must not crash get_fields().
226+
field = DictionaryObject()
227+
field[NameObject("/T")] = TextStringObject("CheckBox")
228+
field[NameObject("/FT")] = NameObject("/Btn")
229+
field[NameObject("/AP")] = DictionaryObject()
230+
fields = _reader_with_button_field(field).get_fields()
231+
assert "CheckBox" in fields
232+
assert "/_States_" not in fields["CheckBox"]
233+
234+
# A well-formed /AP /N still collects the appearance states.
235+
normal = DictionaryObject()
236+
normal[NameObject("/Yes")] = NumberObject(1)
237+
appearance = DictionaryObject()
238+
appearance[NameObject("/N")] = normal
239+
field[NameObject("/AP")] = appearance
240+
fields = _reader_with_button_field(field).get_fields()
241+
assert all(state in fields["CheckBox"]["/_States_"] for state in ("/Yes", "/Off"))
242+
243+
244+
def test_build_field__radio_kid_missing_appearance():
245+
# A radio kid lacking /AP (or its /N) must not crash get_fields().
246+
field = DictionaryObject()
247+
field[NameObject("/T")] = TextStringObject("Radio")
248+
field[NameObject("/FT")] = NameObject("/Btn")
249+
field[NameObject("/Ff")] = NumberObject(1 << 15)
250+
field[NameObject("/Kids")] = ArrayObject([DictionaryObject()])
251+
fields = _reader_with_button_field(field).get_fields()
252+
assert list(fields["Radio"]["/_States_"]) == []
253+
254+
211255
@pytest.mark.enable_socket
212256
def test_named_destinations__tree_is_null_object():
213257
url = "https://github.com/user-attachments/files/20885216/test.pdf"

0 commit comments

Comments
 (0)