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