forked from Yara724/api
167 lines
4.8 KiB
TypeScript
167 lines
4.8 KiB
TypeScript
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, "\\)")})`;
|
||
}
|