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