Skip to content
60 changes: 57 additions & 3 deletions commonforms/form_creator.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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),
}
)
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions tests/form_creator_test.py
Original file line number Diff line number Diff line change
@@ -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()