Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions pypdf/_doc_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,19 @@ def _get_qualified_field_name(
)
return cast(str, parent.get("/T", ""))

@staticmethod
def _normal_appearance(appearance: Any) -> DictionaryObject:
"""Return the /N normal-appearance sub-dictionary of an /AP entry."""
appearance = appearance.get_object()
if not isinstance(appearance, DictionaryObject):
raise PdfReadError(f"Expected appearance dictionary, got {appearance!r}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is uncovered. Please check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Added a checkbox case with a non-dictionary /AP, so that raise is exercised now and the branch is covered.

normal = appearance.get("/N")
if normal is not None:
normal = normal.get_object()
if not isinstance(normal, DictionaryObject):
raise PdfReadError(f"Expected /N appearance dictionary, got {normal!r}")
return normal

def _build_field(
self,
field: Union[TreeObject, DictionaryObject],
Expand All @@ -629,17 +642,19 @@ def _build_field(
retval[key][NameObject("/_States_")] = obj[NameObject(FA.Opt)]
if obj.get(FA.FT, "") == "/Btn" and "/AP" in obj:
# Checkbox
retval[key][NameObject("/_States_")] = ArrayObject(
list(obj["/AP"]["/N"].keys())
)
normal = self._normal_appearance(obj["/AP"])
retval[key][NameObject("/_States_")] = ArrayObject(list(normal.keys()))
if "/Off" not in retval[key]["/_States_"]:
retval[key][NameObject("/_States_")].append(NameObject("/Off"))
elif obj.get(FA.FT, "") == "/Btn" and obj.get(FA.Ff, 0) & FA.FfBits.Radio != 0:
states: list[str] = []
retval[key][NameObject("/_States_")] = ArrayObject(states)
for k in obj.get(FA.Kids, {}):
k = k.get_object()
for s in list(k["/AP"]["/N"].keys()):
if "/AP" not in k:
raise PdfReadError(f"Button field kid missing /AP: {k!r}")
normal = self._normal_appearance(k["/AP"])
for s in list(normal.keys()):
if s not in states:
states.append(s)
retval[key][NameObject("/_States_")] = ArrayObject(states)
Expand Down
44 changes: 44 additions & 0 deletions tests/test_doc_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,50 @@ def test_build_destination__short_array():
assert dest["/Type"] == "/FitR"


def _reader_with_button_field(field: DictionaryObject) -> PdfReader:
writer = PdfWriter()
writer.add_blank_page(width=72, height=72)
reference = writer._add_object(field)
acroform = DictionaryObject()
acroform[NameObject("/Fields")] = ArrayObject([reference])
writer.root_object[NameObject("/AcroForm")] = writer._add_object(acroform)
stream = BytesIO()
writer.write(stream)
stream.seek(0)
return PdfReader(stream)


def test_build_field__checkbox_missing_normal_appearance():
# A checkbox whose /AP has no /N sub-dictionary raises a clean PdfReadError
# instead of an uncaught KeyError.
field = DictionaryObject()
field[NameObject("/T")] = TextStringObject("CheckBox")
field[NameObject("/FT")] = NameObject("/Btn")
field[NameObject("/AP")] = DictionaryObject()
with pytest.raises(PdfReadError, match="Expected /N appearance dictionary"):
_reader_with_button_field(field).get_fields()

# A well-formed /AP /N still collects the appearance states.
normal = DictionaryObject()
normal[NameObject("/Yes")] = NumberObject(1)
appearance = DictionaryObject()
appearance[NameObject("/N")] = normal
field[NameObject("/AP")] = appearance
fields = _reader_with_button_field(field).get_fields()
assert list(fields["CheckBox"]["/_States_"]) == ["/Yes", "/Off"]


def test_build_field__radio_kid_missing_appearance():
# A radio kid lacking /AP raises a clean PdfReadError instead of a KeyError.
field = DictionaryObject()
field[NameObject("/T")] = TextStringObject("Radio")
field[NameObject("/FT")] = NameObject("/Btn")
field[NameObject("/Ff")] = NumberObject(1 << 15)
field[NameObject("/Kids")] = ArrayObject([DictionaryObject()])
with pytest.raises(PdfReadError, match="missing /AP"):
_reader_with_button_field(field).get_fields()


@pytest.mark.enable_socket
def test_named_destinations__tree_is_null_object():
url = "https://github.com/user-attachments/files/20885216/test.pdf"
Expand Down
Loading