import QRCode from "qrcode"; import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib"; import { config } from "./config.ts"; import { decryptBytes } from "./docs_crypto.ts"; import { fullName, frontName } from "./mx.ts"; import type { Db } from "./db.ts"; import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts"; const CM = 28.346456692913385; const CARD_W = 6.7 * CM; const CARD_H = 10.5 * CM; const PAGE_W = 792; const PAGE_H = 612; type WorkerRow = { id: number; first_name: string; middle_name: string | null; last_name_p: string; last_name_m: string; curp: string; nss: string; blood_type: string | null; position: string; risk_color: string; risk_text: string; photo_storage?: string | null; photo_iv?: string | null; }; export function badgeVcardUrl(curp: string) { const token = btoa(curp).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); return `${config.vcardBase}${token}`; } export async function badgeQrPng(curp: string): Promise { return await QRCode.toBuffer(badgeVcardUrl(curp), { errorCorrectionLevel: "H", width: 280, margin: 1, }) as Uint8Array; } function hexRgb(hex: string) { const h = hex.replace("#", ""); return rgb( parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255, ); } export async function loadCurrentPhoto(db: Db, workerId: number): Promise { const doc = await db.prepare( `SELECT storage_name, iv FROM documents WHERE worker_id = ? AND type_code = 'foto' AND is_current = true ORDER BY id DESC LIMIT 1`, ).get(workerId) as { storage_name: string; iv: string } | undefined; if (!doc) return null; try { const enc = await getObject(workerDocKey(workerId, doc.storage_name)); return await decryptBytes(doc.iv, enc); } catch { return null; } } async function embedMaybe( pdf: PDFDocument, bytes: Uint8Array | null, ): Promise { if (!bytes) return null; try { return await pdf.embedJpg(bytes); } catch { try { return await pdf.embedPng(bytes); } catch { return null; } } } function drawCentered( page: PDFPage, font: PDFFont, text: string, x: number, y: number, w: number, size: number, color = rgb(0, 0, 0), ) { const tw = font.widthOfTextAtSize(text, size); page.drawText(text, { x: x + Math.max(0, (w - tw) / 2), y, size, font, color }); } export async function generateBadgePdf( db: Db, projectId: number, workerIds: number[], ): Promise { const project = await db.prepare( `SELECT p.name, p.code, p.theme_id, p.logo_left_path, p.logo_right_path, p.tenant_id, COALESCE(NULLIF(c.nombre_comercial, ''), NULLIF(c.name, '')) AS company_name FROM projects p LEFT JOIN companies c ON c.id = p.company_id WHERE p.id = ?`, ).get(projectId) as { name: string; code: string; theme_id: string; logo_left_path: string | null; logo_right_path: string | null; tenant_id?: number | null; company_name?: string | null; } | undefined; if (!project) throw new Error("Proyecto no encontrado"); let companyName = String(project.company_name || "").trim(); if (!companyName && project.tenant_id) { const principal = await db.prepare( `SELECT COALESCE(NULLIF(nombre_comercial, ''), name) AS company_name FROM companies WHERE tenant_id = ? AND kind = 'principal' ORDER BY id LIMIT 1`, ).get(project.tenant_id) as { company_name?: string } | undefined; companyName = String(principal?.company_name || "").trim(); } const workers = await db.prepare( `SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.curp, w.nss, w.blood_type, w.position, r.color AS risk_color, r.text_color AS risk_text FROM workers w JOIN risk_levels r ON r.code = w.risk_code WHERE w.id = ANY(?)`, ).all(workerIds) as WorkerRow[]; const byId = new Map(workers.map((w) => [w.id, w])); const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[]; const pdf = await PDFDocument.create(); const font = await pdf.embedFont(StandardFonts.Helvetica); const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold); let logoL: PDFImage | null = null; let logoR: PDFImage | null = null; if (project.logo_left_path) { try { logoL = await embedMaybe(pdf, await getObject(project.logo_left_path)); } catch { /* text fallback */ } } if (project.logo_right_path) { try { logoR = await embedMaybe(pdf, await getObject(project.logo_right_path)); } catch { /* text fallback */ } } const gap = 4; const totalW = 4 * CARD_W + 3 * gap; const originX = (PAGE_W - totalW) / 2; const topY = PAGE_H - 8 - CARD_H; const botY = 8; const chunk = 4; for (let i = 0; i < ordered.length; i += chunk) { const group = ordered.slice(i, i + chunk); const page = pdf.addPage([PAGE_W, PAGE_H]); for (let c = 0; c < group.length; c++) { const w = group[c]; const x = originX + c * (CARD_W + gap); const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id)); const qrPng = await badgeQrPng(w.curp); const qrImg = await pdf.embedPng(qrPng); drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR, companyName); drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code); } } return await pdf.save(); } function drawFittedLeft( page: PDFPage, font: PDFFont, text: string, x: number, y: number, maxWidth: number, size: number, color = rgb(0, 0, 0), ) { if (!text) return; let s = size; while (s > 5 && font.widthOfTextAtSize(text, s) > maxWidth) s -= 0.4; page.drawText(text, { x, y, size: s, font, color }); } function drawFront( page: PDFPage, font: PDFFont, fontBold: PDFFont, x: number, y: number, w: WorkerRow, photo: PDFImage | null, logoL: PDFImage | null, logoR: PDFImage | null, companyName: string, ) { page.drawRectangle({ x, y, width: CARD_W, height: CARD_H, borderColor: rgb(0, 0, 0), borderWidth: 1.5, }); const logoH = CARD_H * 0.16; if (logoL) { const scale = Math.min((CARD_W / 2 - 8) / logoL.width, (logoH - 4) / logoL.height); page.drawImage(logoL, { x: x + 6, y: y + CARD_H - logoH - 4, width: logoL.width * scale, height: logoL.height * scale, }); } else { drawFittedLeft( page, fontBold, companyName.toUpperCase(), x + 6, y + CARD_H - 22, CARD_W / 2 - 10, 7, ); } if (logoR) { const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height); page.drawImage(logoR, { x: x + CARD_W / 2 + 4, y: y + CARD_H - logoH - 4, width: logoR.width * scale, height: logoR.height * scale, }); } const photoW = 3.5 * CM; const photoH = 4.5 * CM; const px = x + (CARD_W - photoW) / 2; const py = y + CARD_H - logoH - photoH - 18; page.drawRectangle({ x: px, y: py, width: photoW, height: photoH, borderColor: rgb(0, 0, 0), borderWidth: 2, }); if (photo) { page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 }); } drawCentered(page, fontBold, w.first_name.toUpperCase(), x, py - 22, CARD_W, 13); drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, CARD_W, 13); const barH = 28; const barY = y + 10; page.drawRectangle({ x: x + 1.5, y: barY, width: CARD_W - 3, height: barH, color: hexRgb(w.risk_color), }); drawCentered( page, fontBold, w.position.toUpperCase(), x, barY + 8, CARD_W, 11, hexRgb(w.risk_text || "#FFFFFF"), ); } function drawBack( page: PDFPage, font: PDFFont, fontBold: PDFFont, x: number, y: number, w: WorkerRow, qr: PDFImage, projectName: string, projectCode: string, ) { page.drawRectangle({ x, y, width: CARD_W, height: CARD_H, borderColor: rgb(0, 0, 0), borderWidth: 1.5, rotate: degrees(180), }); page.drawRectangle({ x: x + CARD_W, y: y + CARD_H, width: CARD_W, height: CARD_H, borderColor: rgb(0, 0, 0), borderWidth: 1.5, rotate: degrees(180), }); const qrSize = CARD_W * 0.5; page.drawImage(qr, { x: x + CARD_W - (CARD_W - qrSize) / 2 - qrSize, y: y + CARD_H - 16 - qrSize, width: qrSize, height: qrSize, rotate: degrees(180), }); const lines = [ `GRUPO SANGUINEO: ${w.blood_type ?? ""}`, `CURP: ${w.curp}`, `NSS: ${w.nss}`, `NOMBRE: ${fullName(w)}`, ]; let ly = y + CARD_H - qrSize - 36; for (const line of lines) { page.drawText(line, { x: x + CARD_W - 12, y: ly, size: 7, font, rotate: degrees(180), maxWidth: CARD_W - 20, }); ly -= 12; } page.drawText((projectCode || "").toUpperCase(), { x: x + CARD_W - 20, y: y + 36, size: 10, font: fontBold, rotate: degrees(180), }); page.drawText(projectName.toUpperCase(), { x: x + CARD_W - 20, y: y + 22, size: 10, font: fontBold, rotate: degrees(180), }); } export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise { const key = badgeJobPdfKey(jobId); await putObject(key, bytes); return key; } function wrap(text: string, font: PDFFont, size: number, max: number): string[] { const words = text.split(/\s+/); const lines: string[] = []; let cur = ""; for (const word of words) { const next = cur ? `${cur} ${word}` : word; if (font.widthOfTextAtSize(next, size) > max && cur) { lines.push(cur); cur = word; } else { cur = next; } } if (cur) lines.push(cur); return lines; } export async function generateLoanReceiptPdf(opts: { title: string; subtitle: string; person: string; lines: { label: string; value: string }[]; }): Promise { const pdf = await PDFDocument.create(); const page = pdf.addPage([612, 792]); const font = await pdf.embedFont(StandardFonts.Helvetica); const bold = await pdf.embedFont(StandardFonts.HelveticaBold); page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) }); page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold }); page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) }); page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold }); let y = 610; for (const row of opts.lines) { page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) }); const valueLines = wrap(row.value, bold, 11, 280); let vy = y; for (const line of valueLines) { page.drawText(line, { x: 280, y: vy, size: 11, font: bold }); vy -= 14; } y = Math.min(y, vy) - 18; } page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) }); return await pdf.save(); }