diff --git a/commonforms/form_creator.py b/commonforms/form_creator.py index 2a78a12..1533b22 100644 --- a/commonforms/form_creator.py +++ b/commonforms/form_creator.py @@ -1,11 +1,12 @@ -from pypdf import PdfWriter, PdfReader +from pypdf import PdfReader, PdfWriter from pypdf.annotations import AnnotationDictionary from pypdf.generic import ( - NameObject, ArrayObject, + DecodedStreamObject, + DictionaryObject, + NameObject, NumberObject, TextStringObject, - DictionaryObject, ) from commonforms.utils import BoundingBox @@ -83,6 +84,44 @@ def __init__( super().__init__() pdf_value = NameObject("/Off") if not value else NameObject("/Yes") + width = max(float(rect[2]) - float(rect[0]), 1) + height = max(float(rect[3]) - float(rect[1]), 1) + + def appearance(checked: bool) -> DecodedStreamObject: + stream = DecodedStreamObject() + stream.update( + { + NameObject("/Type"): NameObject("/XObject"), + NameObject("/Subtype"): NameObject("/Form"), + NameObject("/BBox"): ArrayObject( + [ + NumberObject(0), + NumberObject(0), + NumberObject(width), + NumberObject(height), + ] + ), + NameObject("/Resources"): DictionaryObject(), + } + ) + if checked: + stream.set_data( + f"q 0 0 0 RG {min(width, height) * 0.12:.4f} w " + f"{width * 0.18:.4f} {height * 0.50:.4f} m " + f"{width * 0.43:.4f} {height * 0.23:.4f} l " + f"{width * 0.84:.4f} {height * 0.78:.4f} l S Q".encode() + ) + else: + stream.set_data(b"") + return stream + + normal_appearance = DictionaryObject( + { + NameObject("/Off"): appearance(False), + NameObject("/Yes"): appearance(True), + } + ) + self.update( { NameObject("/Type"): NameObject("/Annot"), @@ -93,6 +132,9 @@ def __init__( NameObject("/Rect"): rect, NameObject("/V"): pdf_value, NameObject("/AS"): pdf_value, + NameObject("/AP"): DictionaryObject( + {NameObject("/N"): normal_appearance} + ), NameObject("/T"): TextStringObject(name), } ) @@ -162,6 +204,18 @@ def add_text_box( def add_checkbox(self, name: str, page: int, bounding_box: BoundingBox) -> None: rect = rect_for(bounding_box, self.writer.pages[page]) checkbox = Checkbox(name=name, rect=rect) + appearance = checkbox[NameObject("/AP")] + if not isinstance(appearance, DictionaryObject): + raise TypeError( + "Generated checkbox has an invalid /AP appearance dictionary" + ) + normal_appearance = appearance[NameObject("/N")] + if not isinstance(normal_appearance, DictionaryObject): + raise TypeError( + "Generated checkbox has an invalid /AP /N appearance dictionary" + ) + for state in (NameObject("/Off"), NameObject("/Yes")): + normal_appearance[state] = self.writer._add_object(normal_appearance[state]) self.writer.add_annotation(page_number=page, annotation=checkbox) def add_signature(self, name: str, page: int, bounding_box: BoundingBox) -> None: diff --git a/tests/form_creator_test.py b/tests/form_creator_test.py new file mode 100644 index 0000000..2810848 --- /dev/null +++ b/tests/form_creator_test.py @@ -0,0 +1,73 @@ +from pypdf import PdfReader, PdfWriter +from pypdf.generic import ContentStream, IndirectObject, NameObject + +from commonforms.form_creator import PyPdfFormCreator +from commonforms.utils import BoundingBox + + +def test_checkbox_appearance_streams_are_indirect_and_flatten_checked_state(tmp_path): + input_path = tmp_path / "blank.pdf" + checkbox_path = tmp_path / "checkbox.pdf" + flattened_path = tmp_path / "flattened.pdf" + + blank_writer = PdfWriter() + blank_writer.add_blank_page(width=612, height=792) + with input_path.open("wb") as output: + blank_writer.write(output) + blank_writer.close() + + creator = PyPdfFormCreator(input_path) + creator.add_checkbox("agree", 0, BoundingBox(x0=0.1, y0=0.1, x1=0.2, y1=0.2)) + creator.save(checkbox_path) + creator.close() + + checkbox_reader = PdfReader(checkbox_path) + annotation = checkbox_reader.pages[0]["/Annots"][0].get_object() + normal_appearances = annotation["/AP"]["/N"] + + for state in ("/Off", "/Yes"): + appearance_reference = normal_appearances.raw_get(NameObject(state)) + assert isinstance(appearance_reference, IndirectObject) + + appearance = appearance_reference.get_object() + assert appearance["/Type"] == "/XObject" + assert appearance["/Subtype"] == "/Form" + assert "/BBox" in appearance + assert "/Resources" in appearance + + # pypdf versions can preserve insignificant PDF whitespace in empty streams. + assert normal_appearances["/Off"].get_data().strip() == b"" + checked_appearance = normal_appearances["/Yes"] + checked_bbox = list(checked_appearance["/BBox"]) + checked_resources = dict(checked_appearance["/Resources"]) + checked_data = checked_appearance.get_data() + assert checked_data + + flatten_writer = PdfWriter(clone_from=checkbox_reader) + flatten_writer.update_page_form_field_values( + None, {"agree": "/Yes"}, auto_regenerate=False, flatten=True + ) + with flattened_path.open("wb") as output: + flatten_writer.write(output) + flatten_writer.close() + checkbox_reader.close() + + flattened_reader = PdfReader(flattened_path) + flattened_page = flattened_reader.pages[0] + operations = ContentStream( + flattened_page.get_contents(), flattened_reader + ).operations + do_operations = [operands for operands, operator in operations if operator == b"Do"] + + assert len(do_operations) == 1 + xobject_name = do_operations[0][0] + flattened_appearance = ( + flattened_page["/Resources"]["/XObject"].raw_get(xobject_name).get_object() + ) + + assert flattened_appearance["/Type"] == "/XObject" + assert flattened_appearance["/Subtype"] == "/Form" + assert list(flattened_appearance["/BBox"]) == checked_bbox + assert dict(flattened_appearance["/Resources"]) == checked_resources + assert flattened_appearance.get_data() == checked_data + flattened_reader.close()