diff --git a/pypdf/_doc_common.py b/pypdf/_doc_common.py index b767f1eea..eb2f4e2af 100644 --- a/pypdf/_doc_common.py +++ b/pypdf/_doc_common.py @@ -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}") + 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], @@ -629,9 +642,8 @@ 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: @@ -639,7 +651,10 @@ def _build_field( 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) diff --git a/tests/test_doc_common.py b/tests/test_doc_common.py index 894b7a53a..fac5a37c5 100644 --- a/tests/test_doc_common.py +++ b/tests/test_doc_common.py @@ -208,6 +208,55 @@ 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 is not a 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")] = ArrayObject() + with pytest.raises(PdfReadError, match="Expected appearance dictionary"): + _reader_with_button_field(field).get_fields() + + # A checkbox whose /AP has no /N sub-dictionary raises likewise. + 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"