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.
This commit is contained in:
justin 2026-06-07 02:34:17 -05:00
parent e6a630ada1
commit b0a8563a93
8 changed files with 994 additions and 19 deletions

View file

@ -215,8 +215,17 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l
var drawing = false;
var hasSig = false;
// Vector capture: stroke paths normalized to the capture box (0..1, origin
// top-left), resolution-independent. Drives the pen-plotter ink pipeline.
var sigStrokes = []; // array of strokes; each stroke = array of {x,y,t}
var curStroke = null;
var strokeStart = 0;
var captureW = 0, captureH = 0;
function resizeCanvas() {
var rect = canvas.getBoundingClientRect();
captureW = rect.width;
captureH = rect.height;
canvas.width = rect.width * 2;
canvas.height = rect.height * 2;
ctx.scale(2, 2);
@ -234,17 +243,51 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
}
canvas.addEventListener("mousedown", function(e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); });
canvas.addEventListener("mouseup", function() { drawing = false; });
canvas.addEventListener("mouseleave", function() { drawing = false; });
canvas.addEventListener("touchstart", function(e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }, {passive:false});
canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); }, {passive:false});
canvas.addEventListener("touchend", function() { drawing = false; });
function beginStroke(p) {
drawing = true;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
curStroke = [];
strokeStart = Date.now();
pushPoint(p);
}
function pushPoint(p) {
if (!curStroke) return;
var w = captureW || 1, h = captureH || 1;
curStroke.push({
x: Math.max(0, Math.min(1, p.x / w)),
y: Math.max(0, Math.min(1, p.y / h)),
t: Date.now() - strokeStart,
});
}
function endStroke() {
drawing = false;
if (curStroke && curStroke.length) sigStrokes.push(curStroke);
curStroke = null;
}
function markSigged() {
hasSig = true;
canvas.classList.add("has-sig");
document.getElementById("sig-hint").textContent = "Signature captured";
updateSubmit();
}
canvas.addEventListener("mousedown", function(e) { beginStroke(getPos(e)); });
canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); pushPoint(p); markSigged(); });
canvas.addEventListener("mouseup", endStroke);
canvas.addEventListener("mouseleave", endStroke);
canvas.addEventListener("touchstart", function(e) { e.preventDefault(); beginStroke(getPos(e)); }, {passive:false});
canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); pushPoint(p); markSigged(); }, {passive:false});
canvas.addEventListener("touchend", endStroke);
document.getElementById("sig-clear").addEventListener("click", function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasSig = false;
sigStrokes = [];
curStroke = null;
canvas.classList.remove("has-sig");
document.getElementById("sig-hint").textContent = "Draw your signature above";
updateSubmit();
@ -290,6 +333,11 @@ body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;l
var signatureData;
if (sigMode === "draw") {
signatureData = { type: "drawn", image_b64: canvas.toDataURL("image/png") };
// Attach the vector strokes (resolution-independent) so the same signing
// event can also drive the pen-plotter ink-signature pipeline.
if (sigStrokes.length) {
signatureData.vector = { v: 1, w: captureW, h: captureH, strokes: sigStrokes };
}
} else {
signatureData = { type: "typed", name: typedInput.value.trim() };
}