Files
mrp-qrcode/lib/pdf.ts
T
jason bc3b78aa33
Build and Push Docker Image / build (push) Successful in 1m6s
fixes
2026-04-21 20:59:55 -05:00

844 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
/** `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). */
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; qty: number };
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();
}
/** 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.
*/
export async function renderPartTravelers(payload: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
}): 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);
// 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");
}
for (const card of payload.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card);
}
return doc.save();
}
// 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}`);
}
}
// ---------------------------------------------------------------------------
// 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;
const totalUnits = data.assembly.qty * data.part.qty;
const partMeta = [
`Part ${data.part.code}`,
data.part.material ? `${data.part.material}` : null,
`${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
]
.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;
const totalUnits = data.assembly.qty * data.part.qty;
const meta = [
data.part.material ? `Material: ${data.part.material}` : null,
`Per-assembly qty: ${data.part.qty}`,
`Assemblies: ${data.assembly.qty}`,
`Total to produce: ${totalUnits}`,
`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`;
}