Skip to content

Commit d1082e2

Browse files
Merge pull request #167 from SuffolkLITLab/codex/real-signature-fields
Create real PDF signature fields
2 parents b90728c + 0afd913 commit d1082e2

3 files changed

Lines changed: 140 additions & 1 deletion

File tree

formfyxer/pdf_wrangling.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,11 @@ def from_pikefield(cls, pike_field: PikeField) -> "FormField":
238238
)
239239

240240
def get_bbox(self) -> BoundingBoxF:
241-
if self.type == FieldType.TEXT or self.type == FieldType.AREA:
241+
if (
242+
self.type == FieldType.TEXT
243+
or self.type == FieldType.AREA
244+
or self.type == FieldType.SIGNATURE
245+
):
242246
return (
243247
self.x,
244248
self.y,
@@ -340,6 +344,55 @@ def _create_only_fields(
340344
c.save()
341345

342346

347+
def _normalize_signature_fields(pdf: Pdf, signature_field_names: Iterable[str]) -> None:
348+
"""Convert ReportLab text fields into PDF signature fields.
349+
350+
ReportLab does not expose an AcroForm signature widget API, so
351+
``_create_only_fields`` creates signature placeholders as text fields. This
352+
function changes those generated widgets to ``/FT /Sig`` before they are
353+
copied into the destination PDF.
354+
"""
355+
signature_names = {
356+
str(field_name).strip()
357+
for field_name in signature_field_names
358+
if str(field_name).strip()
359+
}
360+
if not signature_names or not hasattr(pdf.Root, "AcroForm"):
361+
return
362+
363+
converted = 0
364+
365+
def _normalize_object(obj: Any) -> bool:
366+
nonlocal converted
367+
if obj is None or not hasattr(obj, "get"):
368+
return False
369+
field_name = str(obj.get("/T", "") or "").strip()
370+
if field_name not in signature_names:
371+
return False
372+
if str(obj.get("/FT", "")) != "/Sig":
373+
converted += 1
374+
obj["/FT"] = pikepdf.Name("/Sig")
375+
for key in ("/V", "/DV", "/MaxLen", "/Q", "/DS", "/RV"):
376+
if key in obj:
377+
del obj[key]
378+
return True
379+
380+
for field in pdf.Root.AcroForm.get("/Fields", []):
381+
if _normalize_object(field):
382+
continue
383+
if hasattr(field, "Kids"):
384+
for kid in field.Kids:
385+
if _normalize_object(kid):
386+
kid["/FT"] = pikepdf.Name("/Sig")
387+
388+
if converted:
389+
try:
390+
existing_flags = int(pdf.Root.AcroForm.get("/SigFlags", 0) or 0)
391+
except (TypeError, ValueError):
392+
existing_flags = 0
393+
pdf.Root.AcroForm["/SigFlags"] = existing_flags | 3
394+
395+
343396
def set_fields(
344397
in_file: Union[str, Path, BinaryIO],
345398
out_file: Union[str, Path, BinaryIO],
@@ -384,6 +437,7 @@ def set_fields(
384437
if not fields_per_page:
385438
# Nothing to do, lol
386439
return
440+
fields_per_page = [list(page_fields) for page_fields in fields_per_page]
387441
in_pdf = Pdf.open(in_file, allow_overwriting_input=overwrite)
388442
if hasattr(in_pdf.Root, "AcroForm") and not overwrite:
389443
print("Not going to overwrite the existing AcroForm!")
@@ -392,6 +446,13 @@ def set_fields(
392446
io_obj = io.BytesIO()
393447
_create_only_fields(io_obj, fields_per_page)
394448
temp_pdf = Pdf.open(io_obj)
449+
signature_field_names = [
450+
field.name
451+
for page_fields in fields_per_page
452+
for field in page_fields
453+
if field.type == FieldType.SIGNATURE
454+
]
455+
_normalize_signature_fields(temp_pdf, signature_field_names)
395456

396457
in_pdf = copy_pdf_fields(source_pdf=temp_pdf, destination_pdf=in_pdf)
397458
in_pdf.save(out_file)

formfyxer/tests/test_pdf_labeling_rules.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from reportlab.pdfgen import canvas
99

1010
from formfyxer.pdf_wrangling import (
11+
FieldType,
1112
FormField,
1213
_clamp_rect_to_page,
1314
_estimate_page_anchor_transform,
@@ -134,6 +135,56 @@ def test_copy_pdf_fields_anchor_adjusts_rectangles(self, mock_get_transforms):
134135
source_path.unlink(missing_ok=True)
135136
destination_path.unlink(missing_ok=True)
136137

138+
def test_set_fields_writes_real_signature_fields(self):
139+
with NamedTemporaryFile(suffix=".pdf", delete=False) as base_tmp:
140+
base_path = Path(base_tmp.name)
141+
with NamedTemporaryFile(suffix=".pdf", delete=False) as labeled_tmp:
142+
labeled_path = Path(labeled_tmp.name)
143+
144+
try:
145+
c = canvas.Canvas(str(base_path))
146+
c.drawString(72, 720, "Signature")
147+
c.save()
148+
149+
set_fields(
150+
str(base_path),
151+
str(labeled_path),
152+
[
153+
[
154+
FormField(
155+
"users1_signature",
156+
FieldType.SIGNATURE,
157+
72,
158+
650,
159+
font_size=12,
160+
configs={"width": 140, "height": 24},
161+
),
162+
FormField.make_textbox(
163+
"users1_name", (72, 610, 140, 20), 12
164+
),
165+
]
166+
],
167+
overwrite=True,
168+
)
169+
170+
with pikepdf.Pdf.open(str(labeled_path)) as pdf:
171+
fields = {
172+
str(field.get("/T")): field for field in pdf.Root.AcroForm.Fields
173+
}
174+
self.assertEqual(str(fields["users1_signature"].get("/FT")), "/Sig")
175+
self.assertNotIn("/V", fields["users1_signature"])
176+
self.assertNotIn("/DV", fields["users1_signature"])
177+
self.assertEqual(str(fields["users1_name"].get("/FT")), "/Tx")
178+
self.assertEqual(int(pdf.Root.AcroForm.get("/SigFlags", 0)), 3)
179+
180+
loaded_fields = get_existing_pdf_fields(str(labeled_path))
181+
self.assertEqual(loaded_fields[0][0].type, FieldType.SIGNATURE)
182+
self.assertEqual(loaded_fields[0][0].configs["width"], 140.0)
183+
self.assertEqual(loaded_fields[0][0].configs["height"], 24.0)
184+
finally:
185+
base_path.unlink(missing_ok=True)
186+
labeled_path.unlink(missing_ok=True)
187+
137188
def test_improve_names_with_preferred_names(self):
138189
fields = [[FormField.make_textbox("page_0_field_0", (100, 100, 120, 20), 12)]]
139190
textboxes = [

pyproject.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22
requires = ["setuptools>=40.0.0", "pip"]
33
build-backend = "setuptools.build_meta"
44

5+
[dependency-groups]
6+
dev = [
7+
"boxdetect",
8+
"eyecite",
9+
"mypy",
10+
"numpy",
11+
"ocrmypdf",
12+
"openai",
13+
"opencv-python-headless",
14+
"pandas",
15+
"pandas-stubs",
16+
"pdf2image",
17+
"pdfminer.six",
18+
"pikepdf",
19+
"pytest",
20+
"python-docx",
21+
"python-dotenv",
22+
"reportlab",
23+
"requests",
24+
"sigfig",
25+
"textstat",
26+
"tiktoken",
27+
"transformers",
28+
"types-PyYAML",
29+
"types-requests",
30+
]
31+
532
[tool.black]
633
extend-exclude = '(__init__.py|setup.py)'
734

0 commit comments

Comments
 (0)