import jsPDF from "jspdf"; import { numberToWords } from "./numToWords"; import { MICR_FONT_B64 } from "./micrFont"; // Register MICR font into EACH jsPDF doc instance. // Do NOT cache with a module-level flag — each new doc() object needs its own addFileToVFS call. function ensureMicrFont(doc: jsPDF) { try { doc.addFileToVFS("MICRCHECK.ttf", MICR_FONT_B64); doc.addFont("MICRCHECK.ttf", "MICRCHECK", "normal"); doc.setFont("MICRCHECK", "normal"); } catch { doc.setFont("courier", "bold"); } } // ── Types ──────────────────────────────────────────────────────────────────── export type CheckLineItem = { description: string; amount: number; }; export type CheckData = { companyName: string; companyAddress?: string; bankName?: string; bankAddress?: string; routingNumber?: string; accountNumber?: string; fractionalRouting?: string; checkNumber: number; date: string; payee: string; payeeAddress?: string; amount: number; memo?: string; lineItems?: CheckLineItem[]; signatureDataUrl?: string; printSignature?: boolean; voided?: boolean; }; export type CheckPrintOptions = { position: "top" | "middle" | "bottom"; style: "standard" | "voucher"; fontSize: "small" | "medium" | "large"; /** Global horizontal shift in inches — positive = right, negative = left */ offsetX?: number; /** Global vertical shift in inches — positive = down, negative = up */ offsetY?: number; /** Extra vertical shift for the MICR line only (fine-tune for check stock) */ micrOffsetY?: number; /** Spaces between MICR check# and routing segments (default 1) */ micrGap1?: number; /** Spaces between MICR routing and account segments (default 1) */ micrGap2?: number; /** Per-field position offsets (inches) + visibility, keyed by field. */ fieldPositions?: Record; }; /** Field keys for per-element check positioning (bill-payment check face). */ export const CHECK_FIELD_KEYS = [ "check_number", "company", "bank", "date", "pay_to", "amount_box", "amount_words", "address_block", "memo", "signature", "micr", ] as const; export const CHECK_FIELD_LABELS: Record = { check_number: "Check Number", company: "Company / Return Address", bank: "Bank Name & Address", date: "Date", pay_to: "Pay to the Order Of", amount_box: "Numeric Amount ($)", amount_words: "Written Amount", address_block: "Payee Address Block", memo: "Memo", signature: "Signature Line & Label", micr: "MICR Line", }; // ── Constants ──────────────────────────────────────────────────────────────── const PW = 8.5; // page width inches const PH = 11; // page height const ML = 0.35; // left margin const MR = 0.35; // right margin const CW = PW - ML - MR; // content width const RIGHT = PW - MR; // right edge // Standard 3-part check stock — each section is 1/3 of the page const SECTION_H = PH / 3; // 3.667" per section const CHECK_H = SECTION_H; // check occupies the full top section // ── MICR line builder ──────────────────────────────────────────────────────── // MICRCHECK.ttf maps: C → transit bracket A → on-us bracket // Format matches reference check: C000000305C A263191387A 1100034740184C // C{check#}C A{routing}A {account}C function buildMicr(routing: string, account: string, checkNum: number, gap1 = 1, gap2 = 1): string { const r = (routing ?? "").replace(/\D/g, "").slice(0, 9); const a = (account ?? "").replace(/\D/g, ""); const c = String(checkNum).padStart(9, "0"); if (!r && !a) return ""; const g1 = " ".repeat(Math.max(0, Math.round(gap1))); const g2 = " ".repeat(Math.max(0, Math.round(gap2))); // C{check#}C {gap1} A{routing}A {gap2} {account}C return `C${c}C${g1}A${r}A${g2}${a}C`; } // ── Fill a line to width with a pad character (for written-amount security) ─ function padLine(doc: jsPDF, text: string, x: number, rightEdge: number, padChar: string, fontSize: number): void { doc.setFontSize(fontSize); const textW = doc.getTextWidth(text); const padW = doc.getTextWidth(padChar); const available = rightEdge - x; if (textW >= available) { doc.text(text, x, 0); return; } const padsNeeded = Math.floor((available - textW) / padW); const half = Math.floor(padsNeeded / 2); const padded = padChar.repeat(half) + " " + text.trim() + " " + padChar.repeat(padsNeeded - half); doc.text(padded, x, 0); } // ── Helpers ────────────────────────────────────────────────────────────────── function hline(doc: jsPDF, x1: number, y: number, x2: number, w = 0.006, gray = 0) { doc.setLineWidth(w); doc.setDrawColor(gray); doc.line(x1, y, x2, y); } // ── Check face ─────────────────────────────────────────────────────────────── function drawCheck( doc: jsPDF, originY: number, c: CheckData, ox = 0, micrOy = 0, micrGap1 = 1, micrGap2 = 1, fields: Record = {} ) { const y = (dy: number) => originY + dy; const x = (dx: number) => dx + ox; // Apply global X offset to every horizontal coordinate // Per-field positioning: each labeled element can be nudged (dx/dy in inches) // or hidden. Layered on top of the default layout, so {} renders identically. const F = (k: string): { dx?: number; dy?: number; hidden?: boolean } => (fields && fields[k]) || {}; const hid = (k: string) => F(k).hidden === true; const ax = (k: string) => Number(F(k).dx) || 0; const ay = (k: string) => Number(F(k).dy) || 0; // VOID watermark if (c.voided) { doc.saveGraphicsState(); doc.setFont("helvetica", "bold"); doc.setFontSize(100); doc.setTextColor(220, 50, 50); doc.text("VOID", x(PW / 2), y(CHECK_H / 2), { align: "center", angle: 20 }); doc.restoreGraphicsState(); doc.setTextColor(0); } // ── Check number (top-right) ── if (!hid("check_number")) { doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(0); doc.text(String(c.checkNumber), x(RIGHT + ax("check_number")), y(0.18 + ay("check_number")), { align: "right" }); } // ── Company / return address ── if (!hid("company")) { const dx = ax("company"), dy = ay("company"); doc.setFont("helvetica", "bold"); doc.setFontSize(8.5); doc.setTextColor(0); doc.text(c.companyName, x(ML + dx), y(0.18 + dy)); doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); const companyAddrLines = (c.companyAddress ?? "").split(/\n/).filter(Boolean); companyAddrLines.slice(0, 4).forEach((line, i) => { doc.text(line, x(ML + dx), y(0.32 + i * 0.135 + dy)); }); } // ── Bank name / address ── const bankX = ML + CW * 0.52; if (!hid("bank")) { const dx = ax("bank"), dy = ay("bank"); doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(0); doc.text(c.bankName ?? "", x(bankX + dx), y(0.18 + dy)); doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); if (c.bankAddress) doc.text(c.bankAddress, x(bankX + dx), y(0.32 + dy)); } // ── Date + date line ── if (!hid("date")) { const dx = ax("date"); const dateLineY = y(0.78 + ay("date")); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(0); doc.text(c.date, x(RIGHT + dx), dateLineY - 0.04, { align: "right" }); hline(doc, x(ML + CW * 0.62 + dx), dateLineY, x(RIGHT + dx)); } // ── Pay to the order of ── const payeeX = ML + 0.78; if (!hid("pay_to")) { const dx = ax("pay_to"); const payY = y(1.10 + ay("pay_to")); doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(0); doc.text("PAY TO THE", x(ML + dx), payY - 0.10); doc.text("ORDER OF", x(ML + dx), payY + 0.03); doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.text(c.payee, x(payeeX + dx), payY); hline(doc, x(payeeX - 0.02 + dx), payY + 0.06, x(RIGHT - 1.55 + dx)); } // ── Numeric amount box ── if (!hid("amount_box")) { const dx = ax("amount_box"); const amtY = y(1.10 + ay("amount_box")); doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(0); doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT - 0.08 + dx), amtY, { align: "right" }); doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); doc.setTextColor(80); doc.text("DOLLARS", x(RIGHT - 0.08 + dx), amtY + 0.17, { align: "right" }); doc.setTextColor(0); } // ── Written amount ── if (!hid("amount_words")) { const dx = ax("amount_words"); const writtenY = y(1.40 + ay("amount_words")); const amountWords = numberToWords(c.amount).toUpperCase(); doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(0); const wordsW = doc.getTextWidth(amountWords); const starW = doc.getTextWidth("*"); const starsTotal = Math.max(0, Math.floor((CW * 0.90 - wordsW) / starW) - 2); const leftStars = Math.max(4, Math.floor(starsTotal * 0.18)); const rightStars = Math.max(0, starsTotal - leftStars); doc.text("*".repeat(leftStars) + " " + amountWords + " " + "*".repeat(rightStars), x(ML + dx), writtenY, { maxWidth: CW }); hline(doc, x(ML + dx), writtenY + 0.06, x(RIGHT + dx), 0.008, 0); } // ── Envelope address block ── const addrLines = c.payeeAddress ? c.payeeAddress.split(/\n/).map(s => s.trim()).filter(Boolean) : []; const addrIndentX = ML + 0.35; if (!hid("address_block")) { const dx = ax("address_block"); const addrBlockY = y(1.68 + ay("address_block")); doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(0); doc.text(c.payee, x(addrIndentX + dx), addrBlockY); if (addrLines.length > 0) { doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(30); addrLines.slice(0, 3).forEach((line, i) => { doc.text(line, x(addrIndentX + 0.05 + dx), addrBlockY + 0.155 + i * 0.145); }); doc.setTextColor(0); } } // ── Memo + signature (flow below the address block) ── const addrEndDy = 1.68 + 0.155 + Math.max(addrLines.length, 0) * 0.145; const bottomDy = addrEndDy + 0.30; const sigLabelX = PW / 2 + 0.2; const sigCenterX = sigLabelX + (RIGHT - sigLabelX) / 2; if (!hid("memo")) { const dx = ax("memo"); const bottomLineY = y(bottomDy + ay("memo")); doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(0); doc.text("MEMO", x(ML + dx), bottomLineY); hline(doc, x(ML + 0.5 + dx), bottomLineY, x(ML + 2.8 + dx)); if (c.memo) { doc.setFontSize(7.5); doc.text(c.memo, x(ML + 0.55 + dx), bottomLineY - 0.04); } } if (!hid("signature")) { const dx = ax("signature"); const bottomLineY = y(bottomDy + ay("signature")); // Signature image — rendered full size above the line (bottom edge sits ON // the line). With the label moved below the line, it can use the full height. if (c.printSignature && c.signatureDataUrl) { try { const props = doc.getImageProperties(c.signatureDataUrl); const maxW = RIGHT - sigLabelX - 0.1; const maxH = 0.65; const ratio = props.width / props.height; let sigW = maxW; let sigH = sigW / ratio; if (sigH > maxH) { sigH = maxH; sigW = sigH * ratio; } const sigX = x(sigCenterX + dx - sigW / 2); const sigY = bottomLineY - sigH; doc.addImage(c.signatureDataUrl, "PNG", sigX, sigY, sigW, sigH); } catch { /* silent */ } } // Signature line hline(doc, x(sigLabelX + dx), bottomLineY, x(RIGHT + dx)); // "AUTHORIZED SIGNATURE" label below the line doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(80); doc.text("AUTHORIZED SIGNATURE", x(sigCenterX + dx), bottomLineY + 0.13, { align: "center" }); doc.setTextColor(0); } // ── MICR line (X offset + separate MICR Y fine-tune) ── if (!hid("micr")) { const dx = ax("micr"); const micrY = y(CHECK_H - 0.22 + ay("micr")) + micrOy; const micr = buildMicr( (c.routingNumber ?? "").replace(/\D/g, ""), (c.accountNumber ?? "").replace(/\D/g, ""), c.checkNumber, micrGap1, micrGap2 ); if (micr) { ensureMicrFont(doc); doc.setFontSize(11); doc.setTextColor(0); doc.text(micr, x(PW / 2 + dx), micrY, { align: "center" }); doc.setFont("helvetica", "normal"); } else { doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(160); doc.text("[ Configure routing & account numbers in Settings > Check Setup ]", x(PW / 2 + dx), micrY, { align: "center" }); doc.setTextColor(0); } } } // ── Voucher stub ───────────────────────────────────────────────────────────── function drawStub(doc: jsPDF, originY: number, c: CheckData, ox = 0) { const y = (dy: number) => originY + dy; const x = (dx: number) => dx + ox; const pad = 0.04; doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(0); doc.text(c.companyName, x(ML), y(pad + 0.18)); doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.text(String(c.checkNumber), x(RIGHT), y(pad + 0.18), { align: "right" }); // Separator hline(doc, ML, y(pad + 0.26), RIGHT, 0.006); // Payee name (bold left) | Date (right) const rowY = y(pad + 0.44); doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.text(c.payee, ML, rowY); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.text(c.date, RIGHT, rowY, { align: "right" }); hline(doc, ML, y(pad + 0.52), RIGHT, 0.004, 140); // Line items let itemY = y(pad + 0.70); const lineItemH = 0.165; const items = c.lineItems ?? (c.memo ? [{ description: c.memo, amount: c.amount }] : []); doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); for (const item of items) { const descW = CW - 1.1; // Truncate description if too long let desc = item.description; while (doc.getTextWidth(desc) > descW && desc.length > 10) desc = desc.slice(0, -1); doc.text(desc, x(ML), itemY); doc.text(`$${item.amount.toFixed(2)}`, x(RIGHT), itemY, { align: "right" }); itemY += lineItemH; } // Total at bottom-right const totalY = y(SECTION_H - 0.22); doc.setFont("helvetica", "bold"); doc.setFontSize(9.5); doc.text(`$${c.amount.toFixed(2)}`, x(RIGHT), totalY, { align: "right" }); } // ── Public API ─────────────────────────────────────────────────────────────── export function generateCheckPDF(checks: CheckData[], opts: CheckPrintOptions): string { const doc = new jsPDF({ unit: "in", format: "letter" }); const ox = opts.offsetX ?? 0; const oy = opts.offsetY ?? 0; const micrOy = opts.micrOffsetY ?? 0; const g1 = opts.micrGap1 ?? 1; const g2 = opts.micrGap2 ?? 1; const fp = opts.fieldPositions || {}; checks.forEach((c, idx) => { if (idx > 0) doc.addPage(); if (opts.style === "voucher") { drawCheck(doc, 0 + oy, c, ox, micrOy, g1, g2, fp); drawStub(doc, SECTION_H + oy, c, ox); drawStub(doc, SECTION_H * 2 + oy, c, ox); } else { let posY = 0; if (opts.position === "middle") posY = PH / 2 - CHECK_H / 2; if (opts.position === "bottom") posY = PH - CHECK_H; drawCheck(doc, posY + oy, c, ox, micrOy, g1, g2, fp); } }); return doc.output("dataurlstring"); } /** Red overlay for check-stock alignment calibration */ export function generateTestAlignmentPDF(opts: CheckPrintOptions): string { const doc = new jsPDF({ unit: "in", format: "letter" }); doc.setFont("helvetica", "normal"); const sections = opts.style === "voucher" ? [ { y: 0, label: "CHECK FACE", color: [200, 0, 0] as [number,number,number] }, { y: SECTION_H, label: "STUB 1", color: [0, 120, 0] as [number,number,number] }, { y: SECTION_H*2, label: "STUB 2", color: [0, 0, 180] as [number,number,number] }, ] : [{ y: opts.position === "bottom" ? PH - CHECK_H : opts.position === "middle" ? PH/2 - CHECK_H/2 : 0, label: "CHECK", color: [200,0,0] as [number,number,number] }]; for (const s of sections) { doc.setLineWidth(0.01); doc.setDrawColor(...s.color); doc.setLineDashPattern([0.06, 0.04], 0); doc.rect(ML, s.y + 0.01, CW, SECTION_H - 0.02); doc.setLineDashPattern([], 0); doc.setTextColor(...s.color); doc.setFontSize(10); doc.text(`${s.label} — ${SECTION_H.toFixed(3)}" tall`, ML + 0.1, s.y + 0.22); // MICR band indicator const micrTop = s.y + SECTION_H - 0.5; doc.setFillColor(s.color[0], s.color[1], s.color[2]); doc.setGState(doc.GState({ opacity: 0.1 })); doc.rect(ML, micrTop, CW, 0.38, "F"); doc.setGState(doc.GState({ opacity: 1 })); doc.setFontSize(7); doc.text("MICR band", ML + 0.1, micrTop + 0.2); } doc.setTextColor(100); doc.setFontSize(9); doc.text("Alignment test — print on blank paper, hold against check stock to verify", ML, 10.85); return doc.output("dataurlstring"); }