forked from Yara724/api
167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
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" ? "Vazirmatn-Bold.ttf" : "Vazirmatn-Regular.ttf";
|
|
const candidates = [
|
|
join(process.cwd(), "assets", file),
|
|
join(process.cwd(), "assets", "fonts", file),
|
|
join(process.cwd(), "dist", "assets", file),
|
|
join(process.cwd(), "dist", "assets", "fonts", file),
|
|
];
|
|
for (const path of candidates) {
|
|
if (existsSync(path)) return path;
|
|
}
|
|
throw new Error(
|
|
`Persian PDF font not found (${file}). Place it under assets/ or assets/fonts/.`,
|
|
);
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
/** Ensure at least `neededPts` of vertical space remain; add a page if not. */
|
|
function ensureSpace(
|
|
doc: InstanceType<typeof PDFDocument>,
|
|
neededPts: number,
|
|
): void {
|
|
if (doc.y + neededPts > doc.page.maxY()) {
|
|
doc.addPage();
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Reserve one row height (font 10pt ≈ 14 pts with leading) before drawing.
|
|
ensureSpace(doc, 16);
|
|
|
|
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) => {
|
|
// Reserve space for the gap + section heading + at least one data row below it,
|
|
// so the heading is never stranded alone at the bottom of a page.
|
|
ensureSpace(doc, 60);
|
|
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();
|
|
});
|
|
}
|