mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
3b220a3f26
Removes the "Authorized Signer" text from the payee/address block, moves the "AUTHORIZED SIGNATURE" label to just below the signature line, and raises the signature image cap (0.42 -> 0.65 in) so the signature renders full size in the now-clear space above the line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
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<string, { dx?: number; dy?: number; hidden?: boolean }>;
|
|
};
|
|
|
|
/** 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<string, string> = {
|
|
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<string, { dx?: number; dy?: number; hidden?: boolean }> = {}
|
|
) {
|
|
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");
|
|
}
|