PDF generation

This commit is contained in:
SepehrYahyaee
2026-06-23 15:56:19 +03:30
parent cca3ed01a4
commit 2fc6015213
13 changed files with 1287 additions and 0 deletions

View File

@@ -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;

View 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();
});
}

View 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);
}

View 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, "\\)")})`;
}