forked from Yara724/api
PDF generation
This commit is contained in:
@@ -32,6 +32,63 @@ export function resolveGuiltyPartyUserId(blame: {
|
||||
return null;
|
||||
}
|
||||
|
||||
type BlamePartyRow = {
|
||||
role?: string;
|
||||
person?: {
|
||||
userId?: unknown;
|
||||
phoneNumber?: string;
|
||||
clientId?: unknown;
|
||||
fullName?: string;
|
||||
nationalCodeOfInsurer?: string;
|
||||
nationalCodeOfDriver?: string;
|
||||
insurerLicense?: string;
|
||||
driverLicense?: string;
|
||||
driverIsInsurer?: boolean;
|
||||
birthday?: string;
|
||||
insurerBirthday?: number;
|
||||
driverBirthday?: string | null;
|
||||
};
|
||||
statement?: { admitsGuilt?: boolean; claimsDamage?: boolean };
|
||||
insurance?: Record<string, unknown>;
|
||||
vehicle?: Record<string, unknown>;
|
||||
location?: { lat?: number; lon?: number };
|
||||
};
|
||||
|
||||
/** Full party row for the damaged vehicle (owner / beneficiary side). */
|
||||
export function resolveDamagedPartyRow(blame: {
|
||||
type?: string;
|
||||
parties?: BlamePartyRow[];
|
||||
expert?: { decision?: { guiltyPartyId?: unknown } };
|
||||
blameStatus?: string;
|
||||
}): BlamePartyRow | null {
|
||||
const parties = blame?.parties ?? [];
|
||||
if (!parties.length) return null;
|
||||
|
||||
const isCarBody =
|
||||
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||||
|
||||
if (isCarBody) {
|
||||
return parties.find((p) => p.role === "FIRST") ?? parties[0] ?? null;
|
||||
}
|
||||
|
||||
const guiltyId = resolveGuiltyPartyUserId(blame);
|
||||
if (!guiltyId) {
|
||||
return parties.find((p) => p.statement?.claimsDamage === true) ?? null;
|
||||
}
|
||||
|
||||
const damagedParty = parties.find(
|
||||
(p) =>
|
||||
p.person?.userId != null && String(p.person.userId) !== String(guiltyId),
|
||||
);
|
||||
if (damagedParty) return damagedParty;
|
||||
|
||||
return (
|
||||
parties.find(
|
||||
(p) => p.person && String(p.person.userId ?? "") !== String(guiltyId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */
|
||||
export function resolveDamagedPartyPerson(blame: {
|
||||
type?: string;
|
||||
|
||||
152
src/helpers/persian-pdf-document.ts
Normal file
152
src/helpers/persian-pdf-document.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type PDFDocumentType from "pdfkit";
|
||||
import {
|
||||
isMostlyAscii,
|
||||
pdfKitRtlKeyValue,
|
||||
pdfKitRtlParagraph,
|
||||
} from "./persian-pdf-text";
|
||||
|
||||
// pdfkit publishes CJS (`module.exports = PDFDocument`) without a `.default` export.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const PDFDocument = require("pdfkit") as typeof PDFDocumentType;
|
||||
|
||||
const PAGE_WIDTH = 595.28;
|
||||
const MARGIN = 50;
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
|
||||
const LABEL_COLUMN_WIDTH = CONTENT_WIDTH * 0.44;
|
||||
const VALUE_COLUMN_WIDTH = CONTENT_WIDTH * 0.5;
|
||||
const VALUE_COLUMN_X = MARGIN;
|
||||
const COLON_X = MARGIN + VALUE_COLUMN_WIDTH + 4;
|
||||
const LABEL_COLUMN_X = COLON_X + 10;
|
||||
|
||||
function resolveFontFile(variant: "regular" | "bold"): string {
|
||||
const file =
|
||||
variant === "bold"
|
||||
? "NotoKufiArabic-Bold.ttf"
|
||||
: "NotoKufiArabic-Regular.ttf";
|
||||
const candidates = [
|
||||
join(process.cwd(), "assets", "fonts", file),
|
||||
join(process.cwd(), "dist", "fonts", file),
|
||||
join(process.cwd(), "dist", "assets", "fonts", file),
|
||||
join("/usr/share/fonts/truetype/noto", file),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
throw new Error(
|
||||
`Persian PDF font not found (${file}). Place it under assets/fonts/ or install noto fonts on the host.`,
|
||||
);
|
||||
}
|
||||
|
||||
export type PersianPdfDocument = InstanceType<typeof PDFDocument> & {
|
||||
addTitle: (text: string) => void;
|
||||
addSection: (text: string) => void;
|
||||
addKeyValue: (label: string, value?: string | number | null) => void;
|
||||
addBlank: (lines?: number) => void;
|
||||
};
|
||||
|
||||
function drawRtlLine(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
bold: boolean,
|
||||
): void {
|
||||
const y = doc.y;
|
||||
doc
|
||||
.font(bold ? "FaBold" : "FaRegular")
|
||||
.fontSize(fontSize)
|
||||
.text(pdfKitRtlParagraph(text), MARGIN, y, {
|
||||
width: CONTENT_WIDTH,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
});
|
||||
doc.moveDown(fontSize > 12 ? 0.55 : 0.45);
|
||||
}
|
||||
|
||||
function drawKeyValueRow(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
label: string,
|
||||
value: string,
|
||||
): void {
|
||||
const y = doc.y;
|
||||
const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value);
|
||||
|
||||
if (isMostlyAscii(faValue)) {
|
||||
doc.font("Helvetica").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
|
||||
width: VALUE_COLUMN_WIDTH,
|
||||
align: "left",
|
||||
lineBreak: false,
|
||||
});
|
||||
} else {
|
||||
doc.font("FaRegular").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
|
||||
width: VALUE_COLUMN_WIDTH,
|
||||
align: "left",
|
||||
lineBreak: false,
|
||||
});
|
||||
}
|
||||
|
||||
doc.font("Helvetica").fontSize(10).text(":", COLON_X, y, {
|
||||
lineBreak: false,
|
||||
});
|
||||
|
||||
doc.font("FaRegular").fontSize(10).text(faLabel, LABEL_COLUMN_X, y, {
|
||||
width: LABEL_COLUMN_WIDTH,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
});
|
||||
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
|
||||
export function createPersianPdfDocument(): PersianPdfDocument {
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margin: MARGIN,
|
||||
autoFirstPage: true,
|
||||
}) as PersianPdfDocument;
|
||||
|
||||
doc.registerFont("FaRegular", resolveFontFile("regular"));
|
||||
doc.registerFont("FaBold", resolveFontFile("bold"));
|
||||
doc.font("FaRegular");
|
||||
|
||||
doc.addTitle = (text: string) => {
|
||||
doc.moveDown(0.2);
|
||||
drawRtlLine(doc, text, 16, true);
|
||||
doc.font("FaRegular").fontSize(10);
|
||||
doc.moveDown(0.15);
|
||||
};
|
||||
|
||||
doc.addSection = (text: string) => {
|
||||
doc.moveDown(0.5);
|
||||
drawRtlLine(doc, text, 13, true);
|
||||
doc.font("FaRegular").fontSize(10);
|
||||
doc.moveDown(0.1);
|
||||
};
|
||||
|
||||
doc.addKeyValue = (label: string, value?: string | number | null) => {
|
||||
const v =
|
||||
value === undefined || value === null || value === ""
|
||||
? "-"
|
||||
: String(value);
|
||||
drawKeyValueRow(doc, label, v);
|
||||
};
|
||||
|
||||
doc.addBlank = (lines = 1) => {
|
||||
doc.moveDown(lines * 0.35);
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function persianPdfToBuffer(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
27
src/helpers/persian-pdf-text.ts
Normal file
27
src/helpers/persian-pdf-text.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Strip invisible bidi / ZWNJ chars that render as tofu in embedded Arabic fonts. */
|
||||
export function normalizePersianPdfText(text: string): string {
|
||||
return text
|
||||
.replace(/\u200c/g, " ")
|
||||
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "")
|
||||
.replace(/\u2014/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function pdfKitRtlKeyValue(
|
||||
label: string,
|
||||
value: string,
|
||||
): { label: string; value: string } {
|
||||
return {
|
||||
label: normalizePersianPdfText(label),
|
||||
value: normalizePersianPdfText(value),
|
||||
};
|
||||
}
|
||||
|
||||
export function pdfKitRtlParagraph(text: string): string {
|
||||
return normalizePersianPdfText(text);
|
||||
}
|
||||
|
||||
export function isMostlyAscii(text: string): boolean {
|
||||
return text.length > 0 && !/[\u0600-\u06FF]/.test(text);
|
||||
}
|
||||
166
src/helpers/simple-pdf-writer.ts
Normal file
166
src/helpers/simple-pdf-writer.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
type PdfTextRun = {
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
bold: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** Minimal PDF 1.4 writer (Helvetica) — no external dependencies. */
|
||||
export class SimplePdfWriter {
|
||||
private readonly pageWidth = 595.28;
|
||||
private readonly pageHeight = 841.89;
|
||||
private readonly marginX = 50;
|
||||
private readonly marginTop = 50;
|
||||
private readonly marginBottom = 50;
|
||||
private runs: PdfTextRun[] = [];
|
||||
private pageCount = 1;
|
||||
private cursorY = this.pageHeight - this.marginTop;
|
||||
private pageIndex = 0;
|
||||
|
||||
private ensureSpace(lineHeight: number): void {
|
||||
if (this.cursorY - lineHeight < this.marginBottom) {
|
||||
this.pageIndex += 1;
|
||||
this.pageCount += 1;
|
||||
this.cursorY = this.pageHeight - this.marginTop;
|
||||
}
|
||||
}
|
||||
|
||||
private addRun(text: string, fontSize: number, bold = false): void {
|
||||
const lineHeight = fontSize + 4;
|
||||
this.ensureSpace(lineHeight);
|
||||
this.runs.push({
|
||||
pageIndex: this.pageIndex,
|
||||
x: this.marginX,
|
||||
y: this.cursorY,
|
||||
fontSize,
|
||||
bold,
|
||||
text,
|
||||
});
|
||||
this.cursorY -= lineHeight;
|
||||
}
|
||||
|
||||
addTitle(text: string): void {
|
||||
this.addRun(text, 16, true);
|
||||
this.cursorY -= 4;
|
||||
}
|
||||
|
||||
addSection(text: string): void {
|
||||
this.cursorY -= 6;
|
||||
this.addRun(text, 13, true);
|
||||
this.cursorY -= 2;
|
||||
}
|
||||
|
||||
addLine(text: string): void {
|
||||
this.addRun(text, 10, false);
|
||||
}
|
||||
|
||||
addKeyValue(label: string, value?: string | number | null): void {
|
||||
const v =
|
||||
value === undefined || value === null || value === ""
|
||||
? "-"
|
||||
: String(value);
|
||||
this.addLine(`${label}: ${v}`);
|
||||
}
|
||||
|
||||
addBlank(): void {
|
||||
this.cursorY -= 8;
|
||||
}
|
||||
|
||||
toBuffer(): Buffer {
|
||||
const chunkById: string[] = [];
|
||||
const offsets: number[] = [0];
|
||||
let size = Buffer.byteLength("%PDF-1.4\n", "utf8");
|
||||
|
||||
const addObj = (content: string): number => {
|
||||
const id = chunkById.length;
|
||||
offsets.push(size);
|
||||
const block = `${id} 0 obj\n${content}\nendobj\n`;
|
||||
chunkById.push(block);
|
||||
size += Buffer.byteLength(block, "utf8");
|
||||
return id;
|
||||
};
|
||||
|
||||
chunkById.push("%PDF-1.4\n");
|
||||
|
||||
const fontRegularId = addObj(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
);
|
||||
const fontBoldId = addObj(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
);
|
||||
|
||||
const contentIds: number[] = [];
|
||||
for (let p = 0; p < this.pageCount; p++) {
|
||||
const pageRuns = this.runs.filter((r) => r.pageIndex === p);
|
||||
const cmds: string[] = [];
|
||||
for (const run of pageRuns) {
|
||||
const font = run.bold ? "F2" : "F1";
|
||||
cmds.push("BT");
|
||||
cmds.push(`/${font} ${run.fontSize} Tf`);
|
||||
cmds.push(`1 0 0 1 ${run.x.toFixed(2)} ${run.y.toFixed(2)} Tm`);
|
||||
cmds.push(`${encodePdfText(run.text)} Tj`);
|
||||
cmds.push("ET");
|
||||
}
|
||||
const stream = cmds.join("\n");
|
||||
contentIds.push(
|
||||
addObj(
|
||||
`<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pagesKidsToken = "__PAGES_KIDS__";
|
||||
const pagesId = addObj(
|
||||
`<< /Type /Pages /Kids [${pagesKidsToken}] /Count ${this.pageCount} >>`,
|
||||
);
|
||||
const pageIds: number[] = [];
|
||||
for (let p = 0; p < this.pageCount; p++) {
|
||||
pageIds.push(
|
||||
addObj(
|
||||
`<< /Type /Page /Parent ${pagesId} 0 R /Resources << /Font << /F1 ${fontRegularId} 0 R /F2 ${fontBoldId} 0 R >> >> /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] /Contents ${contentIds[p]} 0 R >>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
|
||||
|
||||
chunkById[pagesId] = chunkById[pagesId].replace(
|
||||
pagesKidsToken,
|
||||
pageIds.map((id) => `${id} 0 R`).join(" "),
|
||||
);
|
||||
|
||||
const body = chunkById.join("");
|
||||
const bodyBuf = Buffer.from(body, "utf8");
|
||||
const xrefAt = bodyBuf.length;
|
||||
const xref: string[] = [
|
||||
`xref\n0 ${offsets.length}\n0000000000 65535 f \n`,
|
||||
];
|
||||
for (let i = 1; i < offsets.length; i++) {
|
||||
xref.push(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`);
|
||||
}
|
||||
const trailer = `trailer\n<< /Size ${offsets.length} /Root ${catalogId} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
return Buffer.concat([bodyBuf, Buffer.from(xref.join("") + trailer, "utf8")]);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF literal string for Helvetica (WinAnsi) — Latin text only. */
|
||||
function encodePdfText(input: string): string {
|
||||
const text = input.normalize("NFC");
|
||||
const safe = [...text]
|
||||
.map((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0x20 && code <= 0x7e) return ch;
|
||||
if (ch === "—" || ch === "–") return "-";
|
||||
if (/\s/.test(ch)) return " ";
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return `(${safe
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")})`;
|
||||
}
|
||||
Reference in New Issue
Block a user