import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib"; import { renderQrPngBuffer, scanUrlForToken } from "@/lib/qr"; /** * Traveler PDF generation. Shipped as pdf-lib (pure JS, no system deps) so * the Docker image stays slim. Two public entry points: * * - renderOperationCard(op) -> single Letter page, big QR * - renderPartTravelers({cover, ops}) -> cover sheet + one card per op * * Everything else in this file is layout glue. Nothing here hits the DB — the * route handlers load the data and hand fully-denormalised structs down so * this module stays easy to unit-test later. */ // Letter @ 72 dpi. Points (pt) are the native pdf-lib unit. const PAGE_WIDTH = 612; const PAGE_HEIGHT = 792; const MARGIN = 48; // 2/3" export interface OperationCardData { project: { code: string; name: string }; assembly: { code: string; name: string }; part: { code: string; name: string; material: string | null; qty: number }; operation: { id: string; sequence: number; name: string; qrToken: string; machineName: string | null; machineKind: string | null; settings: string | null; materialNotes: string | null; instructions: string | null; qcRequired: boolean; plannedMinutes: number | null; plannedUnits: number | null; }; } export interface PurchaseOrderPdfData { po: { id: string; vendor: string; status: string; createdAt: Date; sentAt: Date | null; notes: string | null; }; project: { code: string; name: string }; lines: { partNumber: string; description: string; supplier: string | null; qty: number; unitCost: number | null; }[]; } export interface PartCoverData { project: { code: string; name: string }; assembly: { code: string; name: string }; part: { code: string; name: string; material: string | null; qty: number; notes: string | null; }; files: { label: string; file: { originalName: string; sizeBytes: number; sha256: string } | null; }[]; operations: { sequence: number; name: string; machineName: string | null; qcRequired: boolean; qrToken: string; }[]; } /** Entry point: render a single operation card as a PDF byte array. */ export async function renderOperationCard(data: OperationCardData): Promise { const doc = await PDFDocument.create(); const fonts = await embedFonts(doc); const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); await drawOperationCard(doc, page, fonts, data); return doc.save(); } /** Entry point: render a purchase order PDF for sending to a vendor. */ export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise { const doc = await PDFDocument.create(); const fonts = await embedFonts(doc); const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); drawPurchaseOrder(page, fonts, data); return doc.save(); } /** Entry point: cover sheet + every operation card, all in one PDF. */ export async function renderPartTravelers(payload: { cover: PartCoverData; cards: OperationCardData[]; }): Promise { const doc = await PDFDocument.create(); const fonts = await embedFonts(doc); const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); await drawCoverSheet(doc, coverPage, fonts, payload.cover); for (const card of payload.cards) { const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); await drawOperationCard(doc, page, fonts, card); } return doc.save(); } // --------------------------------------------------------------------------- // Layout helpers // --------------------------------------------------------------------------- interface Fonts { regular: PDFFont; bold: PDFFont; mono: PDFFont; } async function embedFonts(doc: PDFDocument): Promise { return { regular: await doc.embedFont(StandardFonts.Helvetica), bold: await doc.embedFont(StandardFonts.HelveticaBold), mono: await doc.embedFont(StandardFonts.Courier), }; } // pdf-lib uses a Y-up coordinate system anchored at the page's bottom-left. // We track the cursor from the top to keep drawing code readable; `top` is // the y-coordinate of the next line's baseline in pt. interface Cursor { top: number; } function drawText( page: PDFPage, text: string, opts: { x: number; y: number; font: PDFFont; size: number; color?: ReturnType }, ): void { page.drawText(text, { x: opts.x, y: opts.y, font: opts.font, size: opts.size, color: opts.color ?? rgb(0.07, 0.09, 0.15), }); } // Break a long string into lines that fit a given width, honouring \n. We // measure in pt with the font's width table so it actually matches the output. function wrapLines(text: string, font: PDFFont, size: number, maxWidth: number): string[] { const out: string[] = []; for (const rawLine of text.split(/\r?\n/)) { if (rawLine.length === 0) { out.push(""); continue; } const words = rawLine.split(/\s+/); let line = ""; for (const word of words) { const candidate = line.length === 0 ? word : `${line} ${word}`; if (font.widthOfTextAtSize(candidate, size) <= maxWidth) { line = candidate; } else { if (line.length > 0) out.push(line); // Word itself overflows — hard-chop so we don't stall. if (font.widthOfTextAtSize(word, size) > maxWidth) { let chunk = ""; for (const ch of word) { if (font.widthOfTextAtSize(chunk + ch, size) > maxWidth) { out.push(chunk); chunk = ch; } else { chunk += ch; } } line = chunk; } else { line = word; } } } if (line.length > 0) out.push(line); } return out; } // --------------------------------------------------------------------------- // Operation card // --------------------------------------------------------------------------- async function drawOperationCard( doc: PDFDocument, page: PDFPage, fonts: Fonts, data: OperationCardData, ): Promise { const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN }; const contentWidth = PAGE_WIDTH - MARGIN * 2; // --- Header strip: project / assembly ------------------------------------ drawText(page, "TRAVELER CARD", { x: MARGIN, y: cursor.top - 10, font: fonts.bold, size: 10, color: rgb(0.4, 0.4, 0.45), }); drawText(page, `${data.project.code} · ${data.assembly.code}`, { x: PAGE_WIDTH - MARGIN, y: cursor.top - 10, font: fonts.regular, size: 10, color: rgb(0.4, 0.4, 0.45), }); // Right-align fix: pdf-lib doesn't do alignment for us, so re-measure. const rightLabel = `${data.project.code} · ${data.assembly.code}`; const rightLabelW = fonts.regular.widthOfTextAtSize(rightLabel, 10); page.drawRectangle({ x: MARGIN, y: cursor.top - 16, width: contentWidth, height: 0, color: rgb(1, 1, 1), }); // Redraw the right label at the correct x now that we know its width. page.drawRectangle({ x: PAGE_WIDTH - MARGIN - rightLabelW - 1, y: cursor.top - 14, width: rightLabelW + 2, height: 14, color: rgb(1, 1, 1), }); drawText(page, rightLabel, { x: PAGE_WIDTH - MARGIN - rightLabelW, y: cursor.top - 10, font: fonts.regular, size: 10, color: rgb(0.4, 0.4, 0.45), }); cursor.top -= 20; // --- Part name (big) ---------------------------------------------------- const partTitle = data.part.name; const partLines = wrapLines(partTitle, fonts.bold, 22, contentWidth); for (const line of partLines) { cursor.top -= 24; drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 }); } cursor.top -= 14; const partMeta = [ `Part ${data.part.code}`, data.part.material ? `${data.part.material}` : null, `qty ${data.part.qty}`, ] .filter(Boolean) .join(" · "); drawText(page, partMeta, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 11, color: rgb(0.35, 0.4, 0.5), }); cursor.top -= 18; // Divider page.drawLine({ start: { x: MARGIN, y: cursor.top }, end: { x: PAGE_WIDTH - MARGIN, y: cursor.top }, thickness: 0.75, color: rgb(0.8, 0.82, 0.88), }); cursor.top -= 24; // --- Step header + QR side-by-side -------------------------------------- // Left column: step label, name, machine, plan. Right column: QR image. const qrSize = 156; // pt -> ~2.2" on paper, well above the 20 mm phone minimum const qrX = PAGE_WIDTH - MARGIN - qrSize; const qrY = cursor.top - qrSize; const qrBytes = await renderQrPngBuffer(data.operation.qrToken); const qrImage = await doc.embedPng(qrBytes); page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); // Text under QR: scan URL + last 8 of token for manual lookup if it smudges. const scanUrl = scanUrlForToken(data.operation.qrToken); const scanLines = wrapLines(scanUrl, fonts.mono, 7, qrSize); let scanY = qrY - 10; for (const l of scanLines) { drawText(page, l, { x: qrX, y: scanY, font: fonts.mono, size: 7, color: rgb(0.4, 0.4, 0.45), }); scanY -= 9; } // Left column content, constrained so it doesn't collide with the QR. const leftColWidth = qrX - MARGIN - 18; drawText(page, `STEP ${data.operation.sequence}`, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 11, color: rgb(0.38, 0.45, 0.85), }); cursor.top -= 6; const stepNameLines = wrapLines(data.operation.name, fonts.bold, 18, leftColWidth); for (const line of stepNameLines) { cursor.top -= 22; drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 18 }); } cursor.top -= 10; const metaRows: [string, string][] = []; if (data.operation.machineName) { metaRows.push([ "Machine", data.operation.machineKind ? `${data.operation.machineName} (${data.operation.machineKind})` : data.operation.machineName, ]); } if (data.operation.plannedMinutes) metaRows.push(["Planned time", `${data.operation.plannedMinutes} min`]); if (data.operation.plannedUnits) metaRows.push(["Planned units", `${data.operation.plannedUnits}`]); if (data.operation.qcRequired) metaRows.push(["QC", "Required on close-out"]); for (const [k, v] of metaRows) { cursor.top -= 13; drawText(page, k.toUpperCase(), { x: MARGIN, y: cursor.top, font: fonts.bold, size: 7, color: rgb(0.45, 0.5, 0.6), }); const valueLines = wrapLines(v, fonts.regular, 11, leftColWidth); for (let i = 0; i < valueLines.length; i++) { if (i > 0) cursor.top -= 13; drawText(page, valueLines[i], { x: MARGIN + 80, y: cursor.top, font: fonts.regular, size: 11, }); } } // Align the cursor below whichever column is taller (QR column vs. left). cursor.top = Math.min(cursor.top, qrY - 10 - scanLines.length * 9); cursor.top -= 26; // --- Full-width sections ------------------------------------------------- drawSection(page, fonts, cursor, "Instructions", data.operation.instructions, contentWidth); drawSection(page, fonts, cursor, "Material notes", data.operation.materialNotes, contentWidth); drawSection(page, fonts, cursor, "Settings", data.operation.settings, contentWidth, fonts.mono); // --- Footer: sign-off strip --------------------------------------------- const footerY = MARGIN + 20; page.drawLine({ start: { x: MARGIN, y: footerY + 14 }, end: { x: PAGE_WIDTH - MARGIN, y: footerY + 14 }, thickness: 0.5, color: rgb(0.82, 0.84, 0.88), }); drawText(page, "Operator", { x: MARGIN, y: footerY, font: fonts.bold, size: 7, color: rgb(0.45, 0.5, 0.6), }); drawText(page, "Start / End", { x: MARGIN + 180, y: footerY, font: fonts.bold, size: 7, color: rgb(0.45, 0.5, 0.6), }); drawText(page, "Units", { x: MARGIN + 340, y: footerY, font: fonts.bold, size: 7, color: rgb(0.45, 0.5, 0.6), }); drawText(page, "QC", { x: MARGIN + 420, y: footerY, font: fonts.bold, size: 7, color: rgb(0.45, 0.5, 0.6), }); } // Inline helper that renders a labelled prose block and advances the cursor. // Silently does nothing if `body` is empty, so we don't leave orphan labels. function drawSection( page: PDFPage, fonts: Fonts, cursor: Cursor, label: string, body: string | null, width: number, font: PDFFont = fonts.regular, ): void { if (!body || !body.trim()) return; drawText(page, label.toUpperCase(), { x: MARGIN, y: cursor.top, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6), }); cursor.top -= 12; const lines = wrapLines(body.trim(), font, 10, width); for (const line of lines) { drawText(page, line, { x: MARGIN, y: cursor.top, font, size: 10 }); cursor.top -= 13; } cursor.top -= 8; } // --------------------------------------------------------------------------- // Cover sheet // --------------------------------------------------------------------------- async function drawCoverSheet( doc: PDFDocument, page: PDFPage, fonts: Fonts, data: PartCoverData, ): Promise { const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN }; const contentWidth = PAGE_WIDTH - MARGIN * 2; drawText(page, "WORK ORDER", { x: MARGIN, y: cursor.top - 10, font: fonts.bold, size: 10, color: rgb(0.4, 0.4, 0.45), }); cursor.top -= 28; const title = `${data.project.code} · ${data.assembly.code} · ${data.part.code}`; drawText(page, title, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 20 }); cursor.top -= 26; drawText(page, data.part.name, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 14, color: rgb(0.2, 0.25, 0.35), }); cursor.top -= 18; const meta = [ data.part.material ? `Material: ${data.part.material}` : null, `Quantity: ${data.part.qty}`, `Project: ${data.project.name}`, `Assembly: ${data.assembly.name}`, ].filter(Boolean) as string[]; for (const line of meta) { drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 10, color: rgb(0.35, 0.4, 0.5), }); cursor.top -= 13; } cursor.top -= 6; if (data.part.notes && data.part.notes.trim()) { drawSection(page, fonts, cursor, "Part notes", data.part.notes, contentWidth); } // --- Files manifest ------------------------------------------------------ const filesWithContent = data.files.filter((f) => f.file !== null); if (filesWithContent.length > 0) { drawText(page, "FILES", { x: MARGIN, y: cursor.top, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6), }); cursor.top -= 14; for (const f of filesWithContent) { if (!f.file) continue; drawText(page, `${f.label}`, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 10, }); drawText(page, f.file.originalName, { x: MARGIN + 110, y: cursor.top, font: fonts.regular, size: 10, }); drawText(page, `${formatBytes(f.file.sizeBytes)} · sha256 ${f.file.sha256.slice(0, 12)}…`, { x: MARGIN + 110, y: cursor.top - 11, font: fonts.mono, size: 8, color: rgb(0.5, 0.5, 0.55), }); cursor.top -= 26; } cursor.top -= 6; } // --- Operations table ---------------------------------------------------- drawText(page, "OPERATIONS", { x: MARGIN, y: cursor.top, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6), }); cursor.top -= 14; const rowThumb = 44; // QR thumbnail side (pt) const rowGap = 10; const rowHeight = rowThumb + rowGap; for (const op of data.operations) { // Stop drawing if we run out of room; the cards that follow are the // authoritative per-step references anyway. if (cursor.top - rowHeight < MARGIN + 30) { drawText(page, `… ${data.operations.length} operations total (see following pages)`, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 9, color: rgb(0.5, 0.5, 0.55), }); break; } const thumbBytes = await renderQrPngBuffer(op.qrToken, 256); const thumbImg = await doc.embedPng(thumbBytes); const thumbY = cursor.top - rowThumb; page.drawImage(thumbImg, { x: MARGIN, y: thumbY, width: rowThumb, height: rowThumb, }); const textX = MARGIN + rowThumb + 12; drawText(page, `${op.sequence}. ${op.name}`, { x: textX, y: cursor.top - 12, font: fonts.bold, size: 11, }); const subline = [ op.machineName ?? "no machine", op.qcRequired ? "QC" : null, ] .filter(Boolean) .join(" · "); drawText(page, subline, { x: textX, y: cursor.top - 26, font: fonts.regular, size: 9, color: rgb(0.4, 0.45, 0.55), }); drawText(page, scanUrlForToken(op.qrToken), { x: textX, y: cursor.top - 40, font: fonts.mono, size: 7, color: rgb(0.5, 0.5, 0.55), }); cursor.top -= rowHeight; } // --- Footer timestamp ---------------------------------------------------- drawText(page, `Printed ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, { x: MARGIN, y: MARGIN - 10, font: fonts.regular, size: 8, color: rgb(0.55, 0.58, 0.65), }); } // --------------------------------------------------------------------------- // Purchase order // --------------------------------------------------------------------------- function drawPurchaseOrder(page: PDFPage, fonts: Fonts, data: PurchaseOrderPdfData): void { const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN }; const contentWidth = PAGE_WIDTH - MARGIN * 2; // --- Header -------------------------------------------------------------- drawText(page, "PURCHASE ORDER", { x: MARGIN, y: cursor.top - 10, font: fonts.bold, size: 10, color: rgb(0.4, 0.4, 0.45), }); const poRef = `PO-${data.po.id.slice(0, 8).toUpperCase()}`; const poRefW = fonts.bold.widthOfTextAtSize(poRef, 10); drawText(page, poRef, { x: PAGE_WIDTH - MARGIN - poRefW, y: cursor.top - 10, font: fonts.bold, size: 10, }); cursor.top -= 28; drawText(page, data.po.vendor, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22, }); cursor.top -= 26; const meta: string[] = [ `Project: ${data.project.code} — ${data.project.name}`, `Status: ${data.po.status}`, `Issued: ${(data.po.sentAt ?? data.po.createdAt).toISOString().slice(0, 10)}`, ]; for (const m of meta) { drawText(page, m, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 10, color: rgb(0.35, 0.4, 0.5), }); cursor.top -= 13; } cursor.top -= 10; // --- Line table --------------------------------------------------------- const cols = { partNo: MARGIN, desc: MARGIN + 110, qty: MARGIN + 360, unit: MARGIN + 410, total: MARGIN + 480, }; const headerRow = cursor.top; drawText(page, "PART NO.", { x: cols.partNo, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) }); drawText(page, "DESCRIPTION", { x: cols.desc, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) }); drawText(page, "QTY", { x: cols.qty, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) }); drawText(page, "UNIT", { x: cols.unit, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) }); drawText(page, "TOTAL", { x: cols.total, y: headerRow, font: fonts.bold, size: 8, color: rgb(0.45, 0.5, 0.6) }); cursor.top -= 4; page.drawLine({ start: { x: MARGIN, y: cursor.top }, end: { x: PAGE_WIDTH - MARGIN, y: cursor.top }, thickness: 0.75, color: rgb(0.82, 0.84, 0.88), }); cursor.top -= 10; const lineSize = 10; const descWidth = cols.qty - cols.desc - 10; let grandTotal = 0; let hasAnyCost = false; for (const l of data.lines) { const descLines = wrapLines(l.description, fonts.regular, lineSize, descWidth); const rowHeight = Math.max(14, descLines.length * 13) + 4; // Page break guard — if we run out of space, stop. (Multi-page POs can // be added later; for now a single page is fine for typical line counts.) if (cursor.top - rowHeight < MARGIN + 60) { drawText(page, `… additional ${data.lines.length} lines truncated`, { x: MARGIN, y: cursor.top, font: fonts.regular, size: 8, color: rgb(0.5, 0.5, 0.55), }); break; } drawText(page, l.partNumber, { x: cols.partNo, y: cursor.top - 2, font: fonts.mono, size: lineSize, }); for (let i = 0; i < descLines.length; i++) { drawText(page, descLines[i], { x: cols.desc, y: cursor.top - 2 - i * 13, font: fonts.regular, size: lineSize, }); } drawText(page, String(l.qty), { x: cols.qty, y: cursor.top - 2, font: fonts.regular, size: lineSize, }); if (l.unitCost !== null && l.unitCost !== undefined) { hasAnyCost = true; const total = l.unitCost * l.qty; grandTotal += total; drawText(page, l.unitCost.toFixed(2), { x: cols.unit, y: cursor.top - 2, font: fonts.regular, size: lineSize, }); drawText(page, total.toFixed(2), { x: cols.total, y: cursor.top - 2, font: fonts.regular, size: lineSize, }); } else { drawText(page, "—", { x: cols.unit, y: cursor.top - 2, font: fonts.regular, size: lineSize, color: rgb(0.55, 0.58, 0.65), }); } cursor.top -= rowHeight; } // --- Totals -------------------------------------------------------------- if (hasAnyCost) { cursor.top -= 8; page.drawLine({ start: { x: cols.unit, y: cursor.top + 10 }, end: { x: PAGE_WIDTH - MARGIN, y: cursor.top + 10 }, thickness: 0.5, color: rgb(0.82, 0.84, 0.88), }); drawText(page, "TOTAL", { x: cols.unit, y: cursor.top, font: fonts.bold, size: 10, }); drawText(page, grandTotal.toFixed(2), { x: cols.total, y: cursor.top, font: fonts.bold, size: 10, }); cursor.top -= 16; } // --- Notes -------------------------------------------------------------- drawSection(page, fonts, cursor, "Notes", data.po.notes, contentWidth); // --- Footer ------------------------------------------------------------- drawText(page, `Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")}Z`, { x: MARGIN, y: MARGIN - 10, font: fonts.regular, size: 8, color: rgb(0.55, 0.58, 0.65), }); } function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; }