new-site/scripts/document_gen/templates/cms10114_pdf_filler.py
justin b0a8563a93 ink-signature: pen-plotter pipeline for original wet-ink CMS signatures
The Standard no-login CMS path needs an ORIGINAL ink signature on paper
(CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This
adds a pipeline to redraw the provider's own captured strokes in real ink with a
pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied.

- migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1).
- signing page now captures normalized stroke paths alongside the PNG; API
  stores a size-bounded vector for drawn signatures.
- ink_signature_plotter.py (hardware-independent): fit strokes to the signature
  anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or
  M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a
  digital twin that proves the toolpath lands on the cert line). Gated serial
  sender (dry_run default).
- ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig
  calibration, --plot to stream over USB.
- Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell
  (above the bottom rule, below the label).
- docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk.

Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py
15/15, API tsc clean, Astro build 58 pages.
2026-06-07 02:34:17 -05:00

254 lines
9.8 KiB
Python

"""Fill the official CMS-10114 NPI Application/Update form (flat PDF, overlay).
The CMS-10114 is the "NPI Application/Update Form" (Rev. 02/25, OMB 0938-0931).
Unlike the CMS-855 forms it is a **flat PDF with no AcroForm fields**, so we
fill it by drawing a text overlay at fixed coordinates (reportlab) and merging
that over the original page (pypdf), mirroring the stamper approach used by the
e-sign pipeline.
This is the Standard (no-login) path for NPPES data updates, NPI reactivation,
and deactivation: the provider signs the certification (Section 4A individual /
4B organization) and the signed form is mailed to:
CMS NPI Enumerator Services
Mail Stop DO-01-51
7500 Security Blvd.
Baltimore, MD 21244
(verified against the form, page 5; the older Fargo PO Box 6059 is retired).
Enumerator staff key the data into NPPES; no NPPES/I&A login is required.
Section 1A "Reason for Submittal" has four mutually-exclusive checkboxes:
1 Initial Application, 2 Change of Information, 3 Deactivation, 4 Reactivation.
We drive the checkbox from the service slug / intake reason so the same filler
serves nppes-update (change), npi-reactivation, and deactivation.
Usage:
from scripts.document_gen.templates.cms10114_pdf_filler import fill_cms10114
pdf_bytes, anchors, missing = fill_cms10114(intake, reason="change",
order_number="CO-1234")
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
LOG = logging.getLogger("cms10114_pdf_filler")
# docs/ lives two levels up from scripts/document_gen/templates/
DOCS_DIR = Path(__file__).resolve().parents[3] / "docs"
FORM_PATH = DOCS_DIR / "CMS-10114 Form.pdf"
# Page index (0-based) of the actual fill-in application form pages within the
# downloaded PDF (the first pages are instructions).
PAGE_BASIC = 2 # Section 1 (reason + entity) and Section 2A identity
PAGE_CERT = 4 # Section 4 certification / signature
PAGE_W = 612.0
PAGE_H = 792.0
# Reason-for-submittal checkbox glyph positions (Section 1A). The checkbox sits
# just left of each label; coordinates are the lower-left of where we draw an
# "X". Verified from pdfplumber word boxes on the form page.
REASON_CHECKBOX = {
"initial": {"x": 48, "y": 610},
"change": {"x": 48, "y": 584},
"deactivation": {"x": 325, "y": 610},
"reactivation": {"x": 325, "y": 508},
}
# Where to write the NPI for each reason (the "NPI: (Required)" line beside the
# chosen checkbox). Change uses the left column NPI line; deactivation/
# reactivation use the right column NPI lines.
REASON_NPI_POS = {
"change": {"x": 132, "y": 570},
"deactivation": {"x": 408, "y": 599},
"reactivation": {"x": 408, "y": 529},
}
# Section 2A individual identity fields (the blank row under the Prefix/First/
# Middle/Last header at pdf_y ~320; we write on the entry row just below it).
IDENTITY_POS = {
"first": {"x": 168, "y": 305},
"middle": {"x": 281, "y": 305},
"last": {"x": 370, "y": 305},
}
# Section 4A (individual) printed-name fields under "First*/Middle/Last*" header.
CERT_NAME_POS = {
"first": {"x": 142, "y": 372},
"middle": {"x": 280, "y": 372},
"last": {"x": 429, "y": 372},
}
# Signature line for Section 4A (individual). The cell "1. Practitioner's
# Signature" has its label at pdf_y ~458 and its bottom rule at pdf_y ~441; the
# signer writes in the band between them (x 36..483). We anchor the signature to
# rest just above the bottom rule. The e-sign stamper / pen plotter use this box.
SIGNATURE_FIELDS = [
{"field": "signer", "page": PAGE_CERT,
"rect": [44.0, 442.0, 474.0, 456.0], "page_w": PAGE_W, "page_h": PAGE_H},
]
VALID_REASONS = ("initial", "change", "deactivation", "reactivation")
# Map service slug -> default reason, so callers can pass a slug instead.
SLUG_TO_REASON = {
"nppes-update": "change",
"npi-data-update": "change",
"npi-reactivation": "reactivation",
"npi-deactivation": "deactivation",
"npi-enumeration": "initial",
}
def _split_name(full: str) -> tuple[str, str, str]:
parts = (full or "").split()
if not parts:
return "", "", ""
if len(parts) == 1:
return parts[0], "", ""
if len(parts) == 2:
return parts[0], "", parts[1]
return parts[0], parts[1][0], " ".join(parts[2:])
def normalize_reason(reason_or_slug: str) -> str:
r = (reason_or_slug or "").strip().lower()
if r in VALID_REASONS:
return r
return SLUG_TO_REASON.get(r, "change")
def _build_overlay(reason: str, values: dict) -> bytes:
"""Render a multi-page overlay matching the form's page count."""
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=letter)
def draw(x, y, text, size=9):
if not text:
return
c.setFont("Helvetica", size)
c.drawString(x, y, str(text))
# We only draw on PAGE_BASIC and PAGE_CERT; emit blank pages for the rest so
# page indices line up when merging.
n_pages = max(PAGE_BASIC, PAGE_CERT) + 1
for pi in range(n_pages):
if pi == PAGE_BASIC:
cb = REASON_CHECKBOX.get(reason)
if cb:
draw(cb["x"], cb["y"], "X", size=11)
npi_pos = REASON_NPI_POS.get(reason)
if npi_pos and values.get("npi"):
draw(npi_pos["x"], npi_pos["y"], values["npi"])
draw(IDENTITY_POS["first"]["x"], IDENTITY_POS["first"]["y"], values.get("first"))
draw(IDENTITY_POS["middle"]["x"], IDENTITY_POS["middle"]["y"], values.get("middle"))
draw(IDENTITY_POS["last"]["x"], IDENTITY_POS["last"]["y"], values.get("last"))
elif pi == PAGE_CERT:
draw(CERT_NAME_POS["first"]["x"], CERT_NAME_POS["first"]["y"], values.get("first"))
draw(CERT_NAME_POS["middle"]["x"], CERT_NAME_POS["middle"]["y"], values.get("middle"))
draw(CERT_NAME_POS["last"]["x"], CERT_NAME_POS["last"]["y"], values.get("last"))
c.showPage()
c.save()
return buf.getvalue()
def fill_cms10114(intake: dict, reason: str = "change",
order_number: str = "") -> tuple[bytes, list[dict], list[str]]:
"""Fill the official CMS-10114 form via text overlay.
Args:
intake: dict with provider_name/first_name/last_name, npi, practice_state.
reason: one of initial/change/deactivation/reactivation, OR a service
slug (nppes-update, npi-reactivation, ...) which is normalized.
order_number: for logging/traceability only.
Returns (pdf_bytes, signature_anchors, missing_notes).
"""
try:
from pypdf import PdfReader, PdfWriter
except ImportError as exc: # pragma: no cover
raise RuntimeError("pypdf is required to fill CMS-10114") from exc
if not FORM_PATH.exists():
raise FileNotFoundError(f"Official CMS-10114 not found: {FORM_PATH}")
reason = normalize_reason(reason)
provider = intake.get("provider_name", "")
f, m, l = _split_name(provider)
values = {
"first": intake.get("first_name", f),
"middle": intake.get("middle_initial", m),
"last": intake.get("last_name", l),
"npi": (intake.get("npi") or "").strip(),
}
overlay_bytes = _build_overlay(reason, values)
base = PdfReader(str(FORM_PATH))
overlay = PdfReader(io.BytesIO(overlay_bytes))
writer = PdfWriter()
for i, page in enumerate(base.pages):
if i < len(overlay.pages):
page.merge_page(overlay.pages[i])
writer.add_page(page)
buf = io.BytesIO()
writer.write(buf)
pdf_bytes = buf.getvalue()
anchors = []
for sig in SIGNATURE_FIELDS:
llx, lly, urx, ury = sig["rect"]
anchors.append({
"field": sig["field"],
"page": sig["page"],
"x": float(llx) + 4,
"y": float(lly) + 1,
"w": float(urx - llx) - 8,
"h": max(float(ury - lly), 12.0),
"page_w": sig["page_w"],
"page_h": sig["page_h"],
})
missing = []
if reason in ("change", "deactivation", "reactivation") and not values["npi"]:
missing.append(f"NPI is required for a CMS-10114 {reason} but was not provided.")
if not (values["first"] and values["last"]):
missing.append("Provider first/last name not collected — required on the certification.")
if reason == "deactivation":
missing.append("Deactivation reason (Death / Business Dissolved / Other) must be selected by reviewer.")
if reason == "reactivation":
missing.append("Reactivation reason free-text must be completed by reviewer.")
if reason == "change":
missing.append("Mark which specific fields are changing in Section 2/3 before mailing.")
# The overlay targets the individual (Section 4A / Section 2A) path. Org
# filings (Entity Type 2 / Section 4B) need the org variant.
if (intake.get("enumeration_type") or "").upper() in ("NPI-2", "2", "ORGANIZATION"):
missing.append("Organization (NPI-2) detected — complete Section 2B/4B; this overlay targets the individual path.")
LOG.info("[cms10114] order=%s reason=%s npi=%s missing=%d",
order_number, reason, values["npi"], len(missing))
return pdf_bytes, anchors, missing
if __name__ == "__main__": # quick local render for visual verification
sample = {
"provider_name": "Jane Q Smith",
"npi": "1234567893",
"practice_state": "CA",
"enumeration_type": "NPI-1",
}
for r in ("change", "reactivation", "deactivation"):
pdf, anchors, missing = fill_cms10114(sample, reason=r, order_number="CO-TEST")
out = f"/tmp/cms10114_{r}.pdf"
with open(out, "wb") as fh:
fh.write(pdf)
print(f"wrote {out} anchors={len(anchors)} missing={len(missing)}")