import type { Mechanic } from "./data"; import { mechanicQrPayload, isBadgeReady } from "./mechanics"; import { qrMatrix, isDark } from "./qr"; /** * Builds the printable badge sheet as a REAL PDF — vector, not a screenshot of the * page and not a browser print job. * * The QR is drawn module by module as a filled rectangle, so it stays perfectly sharp * at any printer resolution or zoom level. Rasterising it into a PNG first would look * fine on screen and go fuzzy on a badge somebody scans with a phone off a lanyard. * * A4 portrait, four badges per row, cut lines included so the sheet can be guillotined. */ export async function buildBadgesPdf(mechanics: Pick[], title = "Mega Plant — Mechanic Badges"): Promise { const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib"); const doc = await PDFDocument.create(); const page = doc.addPage([595.28, 841.89]); // A4 portrait, points const regular = await doc.embedFont(StandardFonts.Helvetica); const bold = await doc.embedFont(StandardFonts.HelveticaBold); const INK = rgb(0, 0, 0); const GREY = rgb(0.38, 0.42, 0.45); const HAIRLINE = rgb(0.82, 0.85, 0.87); const printables = mechanics.filter(isBadgeReady); const margin = 36; const cols = 3; const gutter = 14; const cellW = (page.getWidth() - margin * 2 - gutter * (cols - 1)) / cols; const cellH = 232; page.drawText(title, { x: margin, y: page.getHeight() - margin + 2, size: 13, font: bold, color: INK, }); page.drawText( `${printables.length} badge${printables.length !== 1 ? "s" : ""} · each QR scans to the name and employee number`, { x: margin, y: page.getHeight() - margin - 12, size: 8.5, font: regular, color: GREY }, ); const top = page.getHeight() - margin - 28; printables.forEach((m, i) => { const col = i % cols; const row = Math.floor(i / cols); const x = margin + col * (cellW + gutter); const y = top - row * (cellH + gutter) - cellH; // Cell border doubles as the cut line. page.drawRectangle({ x, y, width: cellW, height: cellH, borderColor: HAIRLINE, borderWidth: 0.5, }); const matrix = qrMatrix(mechanicQrPayload(m)); const qrSize = 104; const qrX = x + (cellW - qrSize) / 2; const qrY = y + cellH - 20 - qrSize; const unit = qrSize / matrix.size; // White backing first: a PDF page is transparent by default, and a transparent QR // prints as nothing on most printers. page.drawRectangle({ x: qrX, y: qrY, width: qrSize, height: qrSize, color: rgb(1, 1, 1) }); // Merge horizontally adjacent dark modules into one rect — same trick as the SVG, // so the file stays small. for (let my = 0; my < matrix.size; my++) { let run = 0; for (let mx = 0; mx <= matrix.size; mx++) { const dark = mx < matrix.size && isDark(matrix, mx, my); if (dark) { run++; } else if (run > 0) { page.drawRectangle({ x: qrX + (mx - run) * unit, y: qrY + (matrix.size - my - 1) * unit, width: run * unit, height: unit, color: INK, }); run = 0; } } } let cursor = qrY - 18; const nameSize = Math.min(11, (cellW - 12) / (m.name.length * 0.58)); page.drawText(m.name, { x: x + (cellW - bold.widthOfTextAtSize(m.name, nameSize)) / 2, y: cursor, size: nameSize, font: bold, color: INK, }); cursor -= 13; page.drawText(m.employee_code, { x: x + (cellW - regular.widthOfTextAtSize(m.employee_code, 10)) / 2, y: cursor, size: 10, font: regular, color: GREY, }); cursor -= 12; const role = m.role || "Mechanic"; page.drawText(role.toUpperCase(), { x: x + (cellW - regular.widthOfTextAtSize(role.toUpperCase(), 7)) / 2, y: cursor, size: 7, font: regular, color: GREY, }); }); if (printables.length === 0) { page.drawText("No mechanic has an employee number yet — there is nothing to print.", { x: margin, y: page.getHeight() / 2, size: 11, font: regular, color: INK, }); } return doc.save(); } /** Trigger a browser download for the generated bytes. */ export function downloadPdf(bytes: Uint8Array, filename: string) { // Copy into a fresh ArrayBuffer: pdf-lib's Uint8Array can be a view onto a larger // buffer, and Blob would then save the padding too. const blob = new Blob([new Uint8Array(bytes)], { type: "application/pdf" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); // Revoke on the next tick so the click has definitely been handled. setTimeout(() => URL.revokeObjectURL(url), 1000); } /** * The oil drum labels: one QR per tracked oil, printed as a label for the drum or * dispenser. Same vector drawing and the same encoder as the badge sheet, so a label * can never scan to a different oil than the one on screen. */ export async function buildOilLabelsPdf( items: { name: string; code: string; colour: string; drum_litres: number }[], payloadOf: (i: { name: string; code: string }) => string, ): Promise { const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib"); const doc = await PDFDocument.create(); const page = doc.addPage([595.28, 841.89]); const regular = await doc.embedFont(StandardFonts.Helvetica); const bold = await doc.embedFont(StandardFonts.HelveticaBold); const INK = rgb(0, 0, 0); const GREY = rgb(0.38, 0.42, 0.45); const HAIRLINE = rgb(0.82, 0.85, 0.87); const hex = (c: string) => { const m = /^#?([0-9a-f]{6})$/i.exec(c || ""); if (!m) return rgb(0, 0, 0); const n = parseInt(m[1], 16); return rgb(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); }; const margin = 36; page.drawText("Mega Plant — Oil & Diesel Labels", { x: margin, y: page.getHeight() - margin + 2, size: 13, font: bold, color: INK, }); page.drawText("Cut and stick each label on its drum or tank. The QR identifies the product.", { x: margin, y: page.getHeight() - margin - 12, size: 8.5, font: regular, color: GREY, }); const cellW = (page.getWidth() - margin * 2) / 2; const cellH = 250; const top = page.getHeight() - margin - 28; items.forEach((item, i) => { const x = margin + (i % 2) * cellW; const y = top - Math.floor(i / 2) * cellH - cellH; page.drawRectangle({ x: x + 6, y, width: cellW - 12, height: cellH - 6, borderColor: HAIRLINE, borderWidth: 0.5, }); // A colour bar, so the two labels are told apart at a glance on the drum. page.drawRectangle({ x: x + 6, y: y + cellH - 22, width: cellW - 12, height: 10, color: hex(item.colour) }); const matrix = qrMatrix(payloadOf(item)); const qrSize = 120; const qrX = x + (cellW - qrSize) / 2; const qrY = y + cellH - 34 - qrSize; const unit = qrSize / matrix.size; // Transparent pages print as nothing, so the backing plate is not optional. page.drawRectangle({ x: qrX, y: qrY, width: qrSize, height: qrSize, color: rgb(1, 1, 1) }); for (let my = 0; my < matrix.size; my++) { let run = 0; for (let mx = 0; mx <= matrix.size; mx++) { const dark = mx < matrix.size && isDark(matrix, mx, my); if (dark) { run++; } else if (run > 0) { page.drawRectangle({ x: qrX + (mx - run) * unit, y: qrY + (matrix.size - my - 1) * unit, width: run * unit, height: unit, color: INK, }); run = 0; } } } let cursor = qrY - 20; page.drawText(item.name, { x: x + (cellW - bold.widthOfTextAtSize(item.name, 14)) / 2, y: cursor, size: 14, font: bold, color: INK, }); cursor -= 15; page.drawText(item.code, { x: x + (cellW - regular.widthOfTextAtSize(item.code, 11)) / 2, y: cursor, size: 11, font: regular, color: GREY, }); cursor -= 13; const drum = `${item.drum_litres || 210} L ${item.code.startsWith("FUEL") ? "tank" : "drum"}`; page.drawText(drum, { x: x + (cellW - regular.widthOfTextAtSize(drum, 9)) / 2, y: cursor, size: 9, font: regular, color: GREY, }); }); return doc.save(); }