mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
225 lines
8.7 KiB
TypeScript
225 lines
8.7 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
|
|
|
const MIN_VALID_PDF_BYTES = 2_000;
|
|
|
|
@Injectable()
|
|
export class WarehouseReleaseDocumentService {
|
|
constructor(private readonly pdf: PdfRenderService) {}
|
|
|
|
/**
|
|
* Render the gate-clearance release document to PDF via the shared renderer,
|
|
* falling back to the release-specific hand-built layout when Chromium is
|
|
* unavailable.
|
|
*/
|
|
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
|
return this.pdf.htmlToPdfBuffer(html, {
|
|
label: 'Warehouse release',
|
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
|
});
|
|
}
|
|
|
|
private htmlToBasicPdfBuffer(html: string): Buffer {
|
|
const doc = this.extractReleaseDocument(html);
|
|
const body: string[] = [
|
|
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
|
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
|
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
|
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
|
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
|
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
|
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
|
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
|
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
|
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
|
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
|
|
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
|
|
...this.wrapLines(doc.notice, 68)
|
|
.slice(0, 4)
|
|
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
|
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
|
];
|
|
|
|
let y = 586;
|
|
const rowHeight = 20;
|
|
for (const [label, value] of doc.rows.slice(0, 14)) {
|
|
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
|
|
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
|
|
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
|
|
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
|
|
y -= rowHeight;
|
|
}
|
|
|
|
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
|
|
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
|
|
body.push(
|
|
...this.wrapLines(doc.clause, 92)
|
|
.slice(0, 4)
|
|
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
|
|
);
|
|
|
|
const stream = [
|
|
...body,
|
|
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
|
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
|
this.circularSealOps(286, 62, 38),
|
|
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
|
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
|
].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 /Times-Roman >>',
|
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-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');
|
|
}
|
|
|
|
private extractReleaseDocument(html: string): {
|
|
reference: string;
|
|
issuedAt: string;
|
|
notice: string;
|
|
clause: string;
|
|
rows: Array<[string, string]>;
|
|
} {
|
|
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
|
|
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
|
|
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
|
|
const notice = textFromHtml(
|
|
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
|
|
'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.',
|
|
);
|
|
const clause = textFromHtml(
|
|
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
|
|
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
|
|
);
|
|
const rows: Array<[string, string]> = [];
|
|
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
|
|
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
|
|
}
|
|
return { reference, issuedAt, notice, clause, rows };
|
|
}
|
|
|
|
private htmlToPlainText(html: string): string {
|
|
return html
|
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
|
|
.split('\n')
|
|
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
private wrapLines(text: string, width: number): string[] {
|
|
const wrapped: string[] = [];
|
|
for (const rawLine of text.split('\n')) {
|
|
const words = rawLine.split(' ');
|
|
let line = '';
|
|
for (const word of words) {
|
|
const next = line ? `${line} ${word}` : word;
|
|
if (next.length > width && line) {
|
|
wrapped.push(line);
|
|
line = word;
|
|
} else {
|
|
line = next;
|
|
}
|
|
}
|
|
if (line) wrapped.push(line);
|
|
}
|
|
return wrapped.length ? wrapped : ['Warehouse release document'];
|
|
}
|
|
|
|
private escapePdfText(value: string): string {
|
|
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
}
|
|
|
|
private textOp(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
size: number,
|
|
font: 'F1' | 'F2' = 'F1',
|
|
color = '0 0 0',
|
|
): string {
|
|
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
|
|
}
|
|
|
|
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
|
|
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
|
}
|
|
|
|
private rectOp(
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
fillColor = '1 1 1',
|
|
strokeColor = '0.08 0.32 0.18',
|
|
lineWidth = 0.8,
|
|
): string {
|
|
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
|
}
|
|
|
|
private circularSealOps(cx: number, cy: number, radius = 51): string {
|
|
return [
|
|
'q',
|
|
'0.08 0.32 0.18 RG',
|
|
'0.08 0.32 0.18 rg',
|
|
'2.2 w',
|
|
this.circlePath(cx, cy, radius),
|
|
'S',
|
|
'0.8 w',
|
|
this.circlePath(cx, cy, radius - 10),
|
|
'S',
|
|
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
|
|
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
|
|
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
|
'Q',
|
|
].join('\n');
|
|
}
|
|
|
|
private 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');
|
|
}
|
|
}
|