Files
mrp-qrcode/lib/pdf.ts
T

844 lines
25 KiB
TypeScript
Raw Normal View History

2026-04-21 13:14:27 -05:00
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 };
2026-04-21 20:59:55 -05:00
/** `qty` is the number of assemblies of this kind in the project. */
assembly: { code: string; name: string; qty: number };
/** `qty` is the per-assembly part count (so total parts = assembly.qty × part.qty). */
2026-04-21 13:14:27 -05:00
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 };
2026-04-21 20:59:55 -05:00
assembly: { code: string; name: string; qty: number };
2026-04-21 13:14:27 -05:00
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<Uint8Array> {
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<Uint8Array> {
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();
}
2026-04-21 20:59:55 -05:00
/** Entry point: cover sheet + every operation card, all in one PDF.
*
* If `drawingPdfBytes` is provided (raw bytes of the part's PDF drawing),
* those pages are inlined right after the cover sheet so the printed stack
* is: cover → drawing(s) → op 1 → op 2 … Operators see the drawing on the
* same sheet they're holding while running the part — no separate print.
*
* Assembly-level drawings can be appended too (`assemblyDrawingPdfBytes`),
* rendered before the part drawing.
*/
2026-04-21 13:14:27 -05:00
export async function renderPartTravelers(payload: {
cover: PartCoverData;
cards: OperationCardData[];
2026-04-21 20:59:55 -05:00
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
2026-04-21 13:14:27 -05:00
}): Promise<Uint8Array> {
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);
2026-04-21 20:59:55 -05:00
// Inline the assembly-level drawing first, then the part drawing. Both are
// optional. We swallow per-PDF errors so a corrupt drawing doesn't block
// the op cards from printing.
if (payload.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, payload.assemblyDrawingPdfBytes, "assembly drawing");
}
if (payload.drawingPdfBytes) {
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing");
}
2026-04-21 13:14:27 -05:00
for (const card of payload.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card);
}
return doc.save();
}
2026-04-21 20:59:55 -05:00
// Load an external PDF's pages and copy them into `doc`. Best-effort: if the
// upstream PDF is unreadable we log to stderr (server-side) and skip; the
// caller's traveler PDF is still produced.
async function appendPdfPages(doc: PDFDocument, bytes: Uint8Array, label: string): Promise<void> {
try {
const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
const pages = await doc.copyPages(src, src.getPageIndices());
for (const p of pages) doc.addPage(p);
} catch (err) {
console.warn(`[travelers.pdf] skipped ${label}: ${(err as Error).message}`);
}
}
2026-04-21 13:14:27 -05:00
// ---------------------------------------------------------------------------
// Layout helpers
// ---------------------------------------------------------------------------
interface Fonts {
regular: PDFFont;
bold: PDFFont;
mono: PDFFont;
}
async function embedFonts(doc: PDFDocument): Promise<Fonts> {
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<typeof rgb> },
): 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<void> {
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;
2026-04-21 20:59:55 -05:00
const totalUnits = data.assembly.qty * data.part.qty;
2026-04-21 13:14:27 -05:00
const partMeta = [
`Part ${data.part.code}`,
data.part.material ? `${data.part.material}` : null,
2026-04-21 20:59:55 -05:00
`${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
2026-04-21 13:14:27 -05:00
]
.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<void> {
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;
2026-04-21 20:59:55 -05:00
const totalUnits = data.assembly.qty * data.part.qty;
2026-04-21 13:14:27 -05:00
const meta = [
data.part.material ? `Material: ${data.part.material}` : null,
2026-04-21 20:59:55 -05:00
`Per-assembly qty: ${data.part.qty}`,
`Assemblies: ${data.assembly.qty}`,
`Total to produce: ${totalUnits}`,
2026-04-21 13:14:27 -05:00
`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`;
}