forked from Yara724/api
PDF generation
This commit is contained in:
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