Capture-to-form signature placement so the customer's drawn or typed signature lands right on the signature rule of the actual form, not in a sidecar page. - migration 085: esign_records.signature_anchors (JSONB exact PDF coords, lower-left origin, points) + signed_document_minio_key - signature_stamper.py: signature_box() anchors; anchors_from_acroform() pulls the signature field /Rect from a real AcroForm (e.g. MCS-150 certifySignature); stamp_signature() overlays PNG (auto-trimmed so ink rests on the rule) or typed name, scaled to actual page size - state_trucking_authorization.py: renders the Limited Authorization to File PDF and returns (pdf_bytes, anchors) - esign_stamp.py: stamp_esign_document() downloads unsigned PDF, stamps, uploads _signed.pdf, sets signed_document_minio_key (idempotent) - dot_esign.py: extract certifySignature anchor for MCS-150/closeout forms so the federal perjury cert is signed on the line - state_trucking.py: authorization gate — first run emails signing link and PAUSES; resumes with client_approved after signing - job_server handle_esign_completed: stamp then re-dispatch - tests: test_signature_placement.py (custom form), and test_mcs150_signature_placement.py (official AcroForm) both assert the signature lands inside the recorded signature box (verified visually)
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""Stamp a captured e-signature onto the exact signature line of a PDF form.
|
|
|
|
Requirement: the client's drawn or typed signature must land **right on top of
|
|
where the signature belongs** on the generated form, not in a separate
|
|
"signature page" sidecar.
|
|
|
|
How it works
|
|
------------
|
|
1. When a document generator draws a signature line, it records the exact PDF
|
|
coordinates of every signature box (see ``signature_box`` below). Those
|
|
anchors are stored in ``esign_records.signature_anchors``.
|
|
2. After the client signs, :func:`stamp_signature` builds a transparent overlay
|
|
the same size as each page, draws the signature image (or a script-font
|
|
rendering of the typed name) inside the recorded box, then merges the overlay
|
|
onto the page with pypdf. The result is a flattened, signed PDF.
|
|
|
|
The overlay is authored in PDF user-space points with the origin at the
|
|
lower-left corner, matching reportlab and the coordinates the generators record,
|
|
so a box recorded at ``(x, y, w, h)`` receives the signature at that exact spot.
|
|
|
|
Only reportlab + pypdf + Pillow-free stdlib are required; if Pillow is present we
|
|
use it to read the PNG's intrinsic aspect ratio, otherwise reportlab reads it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import logging
|
|
from typing import Any
|
|
|
|
LOG = logging.getLogger("workers.services.signature_stamper")
|
|
|
|
# Default signature-box geometry (PDF points). 1 inch = 72 pt.
|
|
SIG_BOX_W = 216.0 # 3.0 in
|
|
SIG_BOX_H = 40.0 # signature sits in a ~0.55 in tall band above the rule
|
|
PAGE_W = 612.0 # US Letter portrait
|
|
PAGE_H = 792.0
|
|
|
|
|
|
def signature_box(
|
|
field: str,
|
|
page: int,
|
|
x: float,
|
|
y: float,
|
|
w: float = SIG_BOX_W,
|
|
h: float = SIG_BOX_H,
|
|
page_w: float = PAGE_W,
|
|
page_h: float = PAGE_H,
|
|
) -> dict[str, Any]:
|
|
"""Build a signature-anchor record for a generator to persist.
|
|
|
|
Coordinates are the **lower-left** corner of the box and the box height/width,
|
|
in PDF points, authored against a page of ``page_w`` x ``page_h``. The
|
|
signature is drawn to sit on top of the signature rule (the rule should be at
|
|
or just below ``y``).
|
|
"""
|
|
return {
|
|
"field": field,
|
|
"page": int(page),
|
|
"x": float(x),
|
|
"y": float(y),
|
|
"w": float(w),
|
|
"h": float(h),
|
|
"page_w": float(page_w),
|
|
"page_h": float(page_h),
|
|
}
|
|
|
|
|
|
def anchors_from_acroform(
|
|
pdf_bytes: bytes,
|
|
field_map: dict[str, str],
|
|
*,
|
|
password: str = "",
|
|
grow_up: float = 22.0,
|
|
drop: float = 7.0,
|
|
left_pad: float = 2.0,
|
|
) -> list[dict[str, Any]]:
|
|
"""Extract signature anchors from a real AcroForm PDF by field name.
|
|
|
|
``field_map`` maps an AcroForm field name (e.g. ``"certifySignature"``) to the
|
|
logical anchor ``field`` we want to store (e.g. ``"signer"``). The field's
|
|
``/Rect`` becomes the box; the signature *line* (visible rule) usually sits a
|
|
few points below the field's lower edge, so we lower the box by ``drop`` so the
|
|
stamped signature rests on the rule, and grow it upward by ``grow_up`` so a
|
|
tall signature is not clipped.
|
|
|
|
Coordinates are returned in PDF points (lower-left origin) against the field's
|
|
own page size, exactly what :func:`stamp_signature` expects.
|
|
"""
|
|
from pypdf import PdfReader
|
|
|
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
|
if reader.is_encrypted:
|
|
try:
|
|
reader.decrypt(password)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
LOG.warning("Could not decrypt AcroForm PDF: %s", exc)
|
|
|
|
anchors: list[dict[str, Any]] = []
|
|
for page_index, page in enumerate(reader.pages):
|
|
page_w = float(page.mediabox.width)
|
|
page_h = float(page.mediabox.height)
|
|
annots = page.get("/Annots") or []
|
|
for annot in annots:
|
|
try:
|
|
obj = annot.get_object()
|
|
except Exception:
|
|
continue
|
|
name = obj.get("/T")
|
|
if not name:
|
|
continue
|
|
key = str(name)
|
|
if key not in field_map:
|
|
continue
|
|
rect = obj.get("/Rect")
|
|
if not rect or len(rect) != 4:
|
|
continue
|
|
x0, y0, x1, y1 = (float(v) for v in rect)
|
|
llx, lly = min(x0, x1), min(y0, y1)
|
|
urx, ury = max(x0, x1), max(y0, y1)
|
|
box_x = llx + left_pad
|
|
box_y = lly - drop
|
|
box_w = max(10.0, (urx - llx) - left_pad)
|
|
box_h = (ury - lly) + grow_up + drop
|
|
anchors.append(
|
|
signature_box(
|
|
field_map[key],
|
|
page_index,
|
|
box_x,
|
|
box_y,
|
|
w=box_w,
|
|
h=box_h,
|
|
page_w=page_w,
|
|
page_h=page_h,
|
|
)
|
|
)
|
|
return anchors
|
|
|
|
|
|
def _decode_png(signature_data: str) -> bytes | None:
|
|
"""Decode a base64 PNG (with or without the data: URI prefix)."""
|
|
if not signature_data:
|
|
return None
|
|
raw = signature_data.split(",", 1)[-1].strip()
|
|
try:
|
|
return base64.b64decode(raw)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
LOG.warning("Could not decode signature PNG: %s", exc)
|
|
return None
|
|
|
|
|
|
def _png_aspect(png_bytes: bytes) -> float:
|
|
"""Width/height aspect ratio of a PNG. Falls back to a sane default."""
|
|
try:
|
|
from PIL import Image # type: ignore
|
|
|
|
with Image.open(io.BytesIO(png_bytes)) as im:
|
|
w, h = im.size
|
|
if h:
|
|
return w / h
|
|
except Exception:
|
|
# Parse the IHDR chunk directly (PNG header) without Pillow.
|
|
try:
|
|
if png_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
|
width = int.from_bytes(png_bytes[16:20], "big")
|
|
height = int.from_bytes(png_bytes[20:24], "big")
|
|
if height:
|
|
return width / height
|
|
except Exception:
|
|
pass
|
|
return 3.0 # typical handwritten signature is wider than tall
|
|
|
|
|
|
def _trim_png(png_bytes: bytes) -> bytes:
|
|
"""Trim transparent/white margins so the ink bounding box fills the image.
|
|
|
|
This lets us bottom-anchor the *ink* (not the canvas) onto the signature
|
|
rule. Falls back to the original bytes if Pillow is unavailable.
|
|
"""
|
|
try:
|
|
from PIL import Image # type: ignore
|
|
except Exception:
|
|
return png_bytes
|
|
try:
|
|
with Image.open(io.BytesIO(png_bytes)) as im:
|
|
im = im.convert("RGBA")
|
|
alpha = im.split()[-1]
|
|
bbox = alpha.getbbox()
|
|
if not bbox or bbox == (0, 0, im.width, im.height):
|
|
# No alpha to trim (e.g. white background) — trim near-white.
|
|
gray = im.convert("L")
|
|
from PIL import ImageOps
|
|
|
|
inverted = ImageOps.invert(gray)
|
|
bbox = inverted.getbbox()
|
|
if bbox:
|
|
im = im.crop(bbox)
|
|
out = io.BytesIO()
|
|
im.save(out, "PNG")
|
|
return out.getvalue()
|
|
except Exception:
|
|
return png_bytes
|
|
|
|
|
|
def _draw_signature_in_box(
|
|
c,
|
|
box: dict[str, Any],
|
|
*,
|
|
signature_type: str,
|
|
signature_data: str,
|
|
page_w: float,
|
|
page_h: float,
|
|
) -> None:
|
|
"""Draw one signature inside ``box`` on an open reportlab canvas.
|
|
|
|
Scales the box coordinates from the authored page size to the actual page
|
|
size so the signature stays on the line even if the page differs slightly.
|
|
"""
|
|
from reportlab.lib.colors import HexColor
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
src_w = box.get("page_w") or page_w
|
|
src_h = box.get("page_h") or page_h
|
|
sx = page_w / src_w if src_w else 1.0
|
|
sy = page_h / src_h if src_h else 1.0
|
|
|
|
bx = box["x"] * sx
|
|
by = box["y"] * sy
|
|
bw = box["w"] * sx
|
|
bh = box["h"] * sy
|
|
|
|
if signature_type == "drawn":
|
|
png = _decode_png(signature_data)
|
|
if not png:
|
|
return
|
|
png = _trim_png(png)
|
|
aspect = _png_aspect(png)
|
|
# Fit the trimmed signature ink inside the box, preserving aspect ratio,
|
|
# left-aligned and bottom-anchored so the ink rests right on the rule. A
|
|
# tiny lift keeps the strokes from colliding with the printed line.
|
|
lift = 1.5
|
|
draw_h = bh
|
|
draw_w = draw_h * aspect
|
|
if draw_w > bw:
|
|
draw_w = bw
|
|
draw_h = draw_w / aspect
|
|
img = ImageReader(io.BytesIO(png))
|
|
c.drawImage(
|
|
img,
|
|
bx,
|
|
by + lift,
|
|
width=draw_w,
|
|
height=draw_h,
|
|
mask="auto",
|
|
preserveAspectRatio=True,
|
|
anchor="sw",
|
|
)
|
|
else: # typed
|
|
name = (signature_data or "").strip()
|
|
if not name:
|
|
return
|
|
# Render the typed name in an italic/script style, vertically centered in
|
|
# the box, resting just above the rule.
|
|
font_name = "Helvetica-Oblique"
|
|
font_size = min(bh * 0.7, 22.0)
|
|
c.setFont(font_name, font_size)
|
|
c.setFillColor(HexColor("#0b1f3a"))
|
|
# Baseline a few points above the box bottom so the name sits on the line.
|
|
c.drawString(bx + 2, by + max(3.0, bh * 0.18), name)
|
|
|
|
|
|
def stamp_signature(
|
|
pdf_bytes: bytes,
|
|
anchors: list[dict[str, Any]],
|
|
*,
|
|
signature_type: str,
|
|
signature_data: str,
|
|
signer_field: str | None = None,
|
|
) -> bytes:
|
|
"""Return a new PDF with the signature stamped onto each matching anchor.
|
|
|
|
Args:
|
|
pdf_bytes: the unsigned form PDF.
|
|
anchors: list of signature-box dicts (see :func:`signature_box`).
|
|
signature_type: "drawn" or "typed".
|
|
signature_data: base64 PNG (drawn) or the typed full name.
|
|
signer_field: if given, only stamp anchors whose ``field`` matches;
|
|
otherwise stamp every anchor.
|
|
|
|
The signature is placed at the recorded coordinates, so it lands exactly on
|
|
the signature line the generator drew.
|
|
"""
|
|
from pypdf import PdfReader, PdfWriter
|
|
from reportlab.pdfgen import canvas
|
|
|
|
if not anchors:
|
|
LOG.warning("stamp_signature called with no anchors — returning original PDF")
|
|
return pdf_bytes
|
|
|
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
|
writer = PdfWriter()
|
|
|
|
# Group anchors by page so we build one overlay per signed page.
|
|
by_page: dict[int, list[dict[str, Any]]] = {}
|
|
for a in anchors:
|
|
if signer_field and a.get("field") != signer_field:
|
|
continue
|
|
by_page.setdefault(int(a.get("page", 0)), []).append(a)
|
|
|
|
for page_index, page in enumerate(reader.pages):
|
|
page_w = float(page.mediabox.width)
|
|
page_h = float(page.mediabox.height)
|
|
page_anchors = by_page.get(page_index)
|
|
|
|
if page_anchors:
|
|
buf = io.BytesIO()
|
|
c = canvas.Canvas(buf, pagesize=(page_w, page_h))
|
|
for box in page_anchors:
|
|
_draw_signature_in_box(
|
|
c,
|
|
box,
|
|
signature_type=signature_type,
|
|
signature_data=signature_data,
|
|
page_w=page_w,
|
|
page_h=page_h,
|
|
)
|
|
c.save()
|
|
buf.seek(0)
|
|
overlay = PdfReader(buf).pages[0]
|
|
page.merge_page(overlay)
|
|
|
|
writer.add_page(page)
|
|
|
|
out = io.BytesIO()
|
|
writer.write(out)
|
|
return out.getvalue()
|