new-site/scripts/workers/services/signature_stamper.py
justin 7ed06780bb trucking: stamp e-signature exactly on form signature lines + state authorization gate
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)
2026-06-02 16:44:19 -05:00

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()