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

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