mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
export loading
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
lineOp,
|
||||
rectOp,
|
||||
sealOp,
|
||||
textOp,
|
||||
textOpRight,
|
||||
wrapText,
|
||||
} from "./styled-pdf.util";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} ${kindLabel}`,
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
* document. Single A4 page; long summaries / line lists are capped to fit.
|
||||
*/
|
||||
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
|
||||
const currency = (cur?: string | null) =>
|
||||
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
|
||||
const money = (amount: unknown, cur?: string | null) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
|
||||
const ops: string[] = [];
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
|
||||
const titleSize = heading.length > 34 ? 18 : 22;
|
||||
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
|
||||
|
||||
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
|
||||
ops.push(
|
||||
textOpRight(
|
||||
`Status ${model.status}`,
|
||||
559,
|
||||
748,
|
||||
8.5,
|
||||
"F1",
|
||||
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
|
||||
),
|
||||
);
|
||||
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
|
||||
|
||||
// ── Seal ──────────────────────────────────────────────────────────────
|
||||
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
|
||||
|
||||
// ── Summary grid (two columns) ────────────────────────────────────────
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
|
||||
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
|
||||
});
|
||||
y -= 34;
|
||||
|
||||
// ── Line-item table ───────────────────────────────────────────────────
|
||||
const qtyR = 402;
|
||||
const rateR = 486;
|
||||
const amtR = 555;
|
||||
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
|
||||
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
|
||||
if (showCategory) {
|
||||
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
|
||||
}
|
||||
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
|
||||
y -= 18;
|
||||
|
||||
const descChars = showCategory ? 44 : 66;
|
||||
for (const item of model.lines) {
|
||||
if (y < 190) break; // leave room for totals + footer
|
||||
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
|
||||
const rowH = Math.max(18, descLines.length * 10 + 8);
|
||||
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
|
||||
descLines.forEach((line, k) => {
|
||||
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
|
||||
});
|
||||
if (showCategory) {
|
||||
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
|
||||
}
|
||||
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
|
||||
y -= rowH;
|
||||
}
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────────
|
||||
let ty = y - 16;
|
||||
for (const total of model.totals) {
|
||||
if (ty < 88) break;
|
||||
if (total.grand) {
|
||||
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
|
||||
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
|
||||
ty -= 24;
|
||||
} else {
|
||||
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
|
||||
ty -= 17;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
|
||||
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
|
||||
|
||||
return assembleSinglePagePdf(ops);
|
||||
}
|
||||
|
||||
/** Truncate to `max` chars with an ellipsis. */
|
||||
private clip(value: string, max: number): string {
|
||||
const text = String(value ?? "");
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Minimal hand-built PDF primitives shared by the Chromium-less document
|
||||
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
|
||||
* rules, right-aligned money, a round seal — so a document still looks like a
|
||||
* real document when headless Chromium is unavailable, instead of degrading to
|
||||
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
|
||||
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
|
||||
*/
|
||||
|
||||
export const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
/** Colours as PDF "r g b" triples in the 0..1 range. */
|
||||
export const PdfColor = {
|
||||
teal: "0.06 0.46 0.43",
|
||||
dark: "0.06 0.09 0.16",
|
||||
gray: "0.39 0.45 0.55",
|
||||
line: "0.80 0.84 0.89",
|
||||
shade: "0.96 0.97 0.98",
|
||||
tint: "0.94 0.99 0.98",
|
||||
} as const;
|
||||
|
||||
export function escapePdfText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
.replace(/[^\x20-\x7e]/g, " ");
|
||||
}
|
||||
|
||||
/** Approximate rendered width of Helvetica text (slightly over-estimated so
|
||||
* right-aligned text never crosses its column edge). */
|
||||
export function textWidth(text: string, size: number): number {
|
||||
return text.length * size * 0.52;
|
||||
}
|
||||
|
||||
export function textOp(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
font: "F1" | "F2" = "F1",
|
||||
color: string = PdfColor.dark,
|
||||
): string {
|
||||
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
}
|
||||
|
||||
/** Right-align `text` so it ends at `rightX`. */
|
||||
export function textOpRight(
|
||||
text: string,
|
||||
rightX: number,
|
||||
y: number,
|
||||
size: number,
|
||||
font: "F1" | "F2" = "F1",
|
||||
color: string = PdfColor.dark,
|
||||
): string {
|
||||
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
|
||||
}
|
||||
|
||||
export function lineOp(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
color: string = PdfColor.line,
|
||||
width = 0.8,
|
||||
): string {
|
||||
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
export function rectOp(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fillColor = "1 1 1",
|
||||
strokeColor: string = PdfColor.line,
|
||||
lineWidth = 0.7,
|
||||
): string {
|
||||
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
||||
}
|
||||
|
||||
function circlePath(cx: number, cy: number, r: number): string {
|
||||
const k = 0.5522847498;
|
||||
const c = r * k;
|
||||
return [
|
||||
`${cx + r} ${cy} m`,
|
||||
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
|
||||
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
|
||||
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
|
||||
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
|
||||
"h",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
|
||||
export function sealOp(
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
lines: string[],
|
||||
color: string = PdfColor.teal,
|
||||
): string {
|
||||
const rows = lines.slice(0, 3);
|
||||
const ops = [
|
||||
"q",
|
||||
`${color} RG`,
|
||||
`${color} rg`,
|
||||
"2 w",
|
||||
circlePath(cx, cy, r),
|
||||
"S",
|
||||
"0.7 w",
|
||||
circlePath(cx, cy, r - 6),
|
||||
"S",
|
||||
];
|
||||
const startY = cy + (rows.length - 1) * 6;
|
||||
rows.forEach((text, i) => {
|
||||
const size = i === 0 ? 10 : 7.5;
|
||||
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
|
||||
});
|
||||
ops.push("Q");
|
||||
return ops.join("\n");
|
||||
}
|
||||
|
||||
/** Greedy word-wrap to a maximum character width. */
|
||||
export function wrapText(text: string, maxChars: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of String(text ?? "").split("\n")) {
|
||||
const words = raw.split(/\s+/).filter(Boolean);
|
||||
let line = "";
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > maxChars && line) {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) out.push(line);
|
||||
}
|
||||
return out.length ? out : [""];
|
||||
}
|
||||
|
||||
/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */
|
||||
export function assembleSinglePagePdf(ops: string[]): Buffer {
|
||||
const stream = ops.join("\n");
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
|
||||
pdf += "% fallback padding\n";
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
Reference in New Issue
Block a user