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:
parent
e6a630ada1
commit
b0a8563a93
8 changed files with 994 additions and 19 deletions
29
api/migrations/090_esign_signature_vector.sql
Normal file
29
api/migrations/090_esign_signature_vector.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- 090: Capture the vector (stroke-path) form of a drawn signature.
|
||||
--
|
||||
-- Today esign_records.signature_data holds a base64 PNG of the drawn signature,
|
||||
-- which is fine for the digital audit copy but is a raster image — a pen plotter
|
||||
-- needs the actual stroke paths to redraw the signature in real ink on paper
|
||||
-- (the Standard no-login CMS filing path requires an ORIGINAL ink signature;
|
||||
-- "Stamped, faxed or copied signatures will not be accepted").
|
||||
--
|
||||
-- We store the captured strokes as JSON so the same signing event yields both:
|
||||
-- * signature_data — base64 PNG (digital stamp, audit trail)
|
||||
-- * signature_vector — stroke paths (drives the pen plotter)
|
||||
--
|
||||
-- Format (normalized into a 0..1 box, origin top-left, matching canvas capture):
|
||||
-- {
|
||||
-- "v": 1,
|
||||
-- "w": <canvas css width px>, "h": <canvas css height px>,
|
||||
-- "strokes": [ [ {"x":0.12,"y":0.40,"t":12}, ... ], ... ]
|
||||
-- }
|
||||
-- x/y are fractions of the capture box (resolution-independent); t is ms since
|
||||
-- stroke start (optional, for future pressure/speed modeling). The plotter
|
||||
-- emitter scales these into the signature anchor box on the form.
|
||||
|
||||
ALTER TABLE esign_records
|
||||
ADD COLUMN IF NOT EXISTS signature_vector JSONB;
|
||||
|
||||
COMMENT ON COLUMN esign_records.signature_vector IS
|
||||
'Stroke-path form of a drawn signature (normalized 0..1, origin top-left). '
|
||||
'Drives the pen-plotter ink-signature pipeline. NULL for typed signatures '
|
||||
'or signatures captured before this column existed.';
|
||||
|
|
@ -117,7 +117,7 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
|
|||
const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!;
|
||||
|
||||
const { signature, agreed_at, user_agent } = req.body as {
|
||||
signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string };
|
||||
signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string; vector?: any };
|
||||
agreed_at?: string;
|
||||
user_agent?: string;
|
||||
};
|
||||
|
|
@ -174,6 +174,23 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
|
|||
? signature.image_b64!.replace(/^data:image\/png;base64,/, "")
|
||||
: signature.name!.trim();
|
||||
|
||||
// Sanitize the optional vector (stroke paths) — bound the size so a hostile
|
||||
// client can't store an enormous JSON blob. Only kept for drawn signatures.
|
||||
let sigVector: string | null = null;
|
||||
if (signature.type === "drawn" && signature.vector && Array.isArray(signature.vector.strokes)) {
|
||||
const v = signature.vector;
|
||||
const strokeCount = v.strokes.length;
|
||||
const pointCount = v.strokes.reduce((n: number, s: any) => n + (Array.isArray(s) ? s.length : 0), 0);
|
||||
if (strokeCount > 0 && strokeCount <= 500 && pointCount > 0 && pointCount <= 20000) {
|
||||
sigVector = JSON.stringify({
|
||||
v: 1,
|
||||
w: Number(v.w) || 0,
|
||||
h: Number(v.h) || 0,
|
||||
strokes: v.strokes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const clientIp = (req as any).clientIp || req.ip || "";
|
||||
const signedAt = new Date().toISOString();
|
||||
|
||||
|
|
@ -182,16 +199,18 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
|
|||
SET status = 'signed',
|
||||
signature_type = $1,
|
||||
signature_data = $2,
|
||||
signer_email = $3,
|
||||
signer_ip = $4,
|
||||
signer_user_agent = $5,
|
||||
signed_at = $6,
|
||||
perjury_agreed = $7,
|
||||
signature_vector = $3,
|
||||
signer_email = $4,
|
||||
signer_ip = $5,
|
||||
signer_user_agent = $6,
|
||||
signed_at = $7,
|
||||
perjury_agreed = $8,
|
||||
updated_at = NOW()
|
||||
WHERE id = $8`,
|
||||
WHERE id = $9`,
|
||||
[
|
||||
signature.type,
|
||||
sigData,
|
||||
sigVector,
|
||||
email,
|
||||
clientIp,
|
||||
user_agent || "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue