panels-origin/api/pdf.ts
2026-08-21 16:55:01 -05:00

350 lines
8.9 KiB
TypeScript

import type { Database } from "@db/sqlite";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
// mkdir used in saveJobPdf
import QRCode from "qrcode";
import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib";
import { config, PDFS_DIR } from "./config.ts";
import { decryptBytes } from "./docs_crypto.ts";
import { fullName, frontName } from "./mx.ts";
import { workerDir } from "./db.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<Uint8Array> {
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: Database, workerId: number): Promise<Uint8Array | null> {
const doc = db.prepare(
`SELECT storage_name, iv FROM documents
WHERE worker_id = ? AND type_code = 'foto' AND is_current = 1
ORDER BY id DESC LIMIT 1`,
).get(workerId) as { storage_name: string; iv: string } | undefined;
if (!doc) return null;
try {
const enc = await Deno.readFile(join(workerDir(workerId), doc.storage_name));
return await decryptBytes(doc.iv, enc);
} catch {
return null;
}
}
async function embedMaybe(
pdf: PDFDocument,
bytes: Uint8Array | null,
): Promise<PDFImage | null> {
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: Database,
projectId: number,
workerIds: number[],
): Promise<Uint8Array> {
const project = db.prepare(
"SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?",
).get(projectId) as {
name: string;
code: string;
theme_id: string;
logo_left_path: string | null;
logo_right_path: string | null;
} | undefined;
if (!project) throw new Error("Proyecto no encontrado");
const placeholders = workerIds.map(() => "?").join(",");
const workers = 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 IN (${placeholders})`,
).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 Deno.readFile(project.logo_left_path));
} catch { /* text fallback */ }
}
if (project.logo_right_path) {
try {
logoR = await embedMaybe(pdf, await Deno.readFile(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);
drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code);
}
}
return await pdf.save();
}
function drawFront(
page: PDFPage,
font: PDFFont,
fontBold: PDFFont,
x: number,
y: number,
w: WorkerRow,
photo: PDFImage | null,
logoL: PDFImage | null,
logoR: PDFImage | null,
) {
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 {
page.drawText("Construcciones Arctec", {
x: x + 6,
y: y + CARD_H - 22,
size: 7,
font: fontBold,
});
}
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,
});
} else {
page.drawText("Zendala", {
x: x + CARD_W / 2 + 8,
y: y + CARD_H - 22,
size: 9,
font: fontBold,
color: rgb(0.1, 0.25, 0.55),
});
}
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 });
}
const name = frontName(w).toUpperCase();
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,
) {
const cx = x + CARD_W / 2;
const cy = y + CARD_H / 2;
page.drawRectangle({
x,
y,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
rotate: degrees(180),
// pdf-lib rotate is around origin of the op; we translate via origin option
});
// Draw rotated content using origin at card center
const opts = { rotate: degrees(180) as ReturnType<typeof degrees> };
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<string> {
await mkdir(PDFS_DIR, { recursive: true });
const path = join(PDFS_DIR, `gafetes-${jobId}.pdf`);
await Deno.writeFile(path, bytes);
return path;
}