155 lines
5.0 KiB
JavaScript
155 lines
5.0 KiB
JavaScript
|
|
// Minimaler, abhängigkeitsfreier PDF-Generator.
|
||
|
|
// Rendert die E-Mail (Kopf + Klartext-Body) als einfaches Text-PDF.
|
||
|
|
// Bewusst schlicht (v1): kein HTML-Rendering, keine Bilder — dafür keine
|
||
|
|
// externen Libraries. Reicht für eine lesbare Aktenkopie der Mail.
|
||
|
|
|
||
|
|
const Pdf = {
|
||
|
|
/**
|
||
|
|
* Erzeugt ein PDF-File aus den Mail-Metadaten.
|
||
|
|
* @param {object} meta Ergebnis von Mail.getMeta()
|
||
|
|
* @returns {File}
|
||
|
|
*/
|
||
|
|
fromMail(meta) {
|
||
|
|
const header = [
|
||
|
|
`Von: ${meta.senderName ? meta.senderName + " <" + meta.senderEmail + ">" : meta.senderEmail}`,
|
||
|
|
`An: ${meta.to}`,
|
||
|
|
meta.cc ? `CC: ${meta.cc}` : null,
|
||
|
|
`Datum: ${this._fmtDate(meta.date)}`,
|
||
|
|
`Betreff: ${meta.subject}`,
|
||
|
|
"",
|
||
|
|
"----------------------------------------------------------------------",
|
||
|
|
"",
|
||
|
|
].filter((l) => l !== null);
|
||
|
|
|
||
|
|
const lines = header.concat(this._wrap(meta.bodyText || "", 95));
|
||
|
|
const bytes = this._build(lines);
|
||
|
|
const name = `${(meta.subject || "email").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 80) || "email"}.pdf`;
|
||
|
|
return new File([bytes], name, { type: "application/pdf" });
|
||
|
|
},
|
||
|
|
|
||
|
|
// --- PDF-Aufbau -----------------------------------------------------------
|
||
|
|
|
||
|
|
_build(allLines) {
|
||
|
|
const PAGE_W = 595, PAGE_H = 842; // A4 in pt
|
||
|
|
const MARGIN = 50, FONT = 10, LEADING = 14;
|
||
|
|
const linesPerPage = Math.floor((PAGE_H - 2 * MARGIN) / LEADING);
|
||
|
|
|
||
|
|
// Seiten aufteilen
|
||
|
|
const pages = [];
|
||
|
|
for (let i = 0; i < allLines.length; i += linesPerPage) {
|
||
|
|
pages.push(allLines.slice(i, i + linesPerPage));
|
||
|
|
}
|
||
|
|
if (pages.length === 0) pages.push([""]);
|
||
|
|
|
||
|
|
const objects = []; // {id, body} body als latin1-String
|
||
|
|
const addObj = (body) => { objects.push(body); return objects.length; };
|
||
|
|
|
||
|
|
// Platzhalter-IDs vorab festlegen
|
||
|
|
// 1 Catalog, 2 Pages, 3 Font, dann je Seite: PageObj + ContentObj
|
||
|
|
const catalogId = 1, pagesId = 2, fontId = 3;
|
||
|
|
objects.length = 0;
|
||
|
|
objects.push("", "", ""); // reserviere 1..3
|
||
|
|
|
||
|
|
const pageIds = [];
|
||
|
|
const contentIds = [];
|
||
|
|
pages.forEach((pageLines) => {
|
||
|
|
const stream = this._contentStream(pageLines, MARGIN, PAGE_H - MARGIN, FONT, LEADING);
|
||
|
|
const contentId = addObj(
|
||
|
|
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`
|
||
|
|
);
|
||
|
|
const pageId = addObj(
|
||
|
|
`<< /Type /Page /Parent ${pagesId} 0 R ` +
|
||
|
|
`/MediaBox [0 0 ${PAGE_W} ${PAGE_H}] ` +
|
||
|
|
`/Resources << /Font << /F1 ${fontId} 0 R >> >> ` +
|
||
|
|
`/Contents ${contentId} 0 R >>`
|
||
|
|
);
|
||
|
|
pageIds.push(pageId);
|
||
|
|
contentIds.push(contentId);
|
||
|
|
});
|
||
|
|
|
||
|
|
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||
|
|
objects[pagesId - 1] =
|
||
|
|
`<< /Type /Pages /Count ${pageIds.length} ` +
|
||
|
|
`/Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] >>`;
|
||
|
|
objects[fontId - 1] =
|
||
|
|
`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`;
|
||
|
|
|
||
|
|
// Serialisieren mit xref
|
||
|
|
let pdf = "%PDF-1.4\n";
|
||
|
|
const offsets = [];
|
||
|
|
objects.forEach((body, idx) => {
|
||
|
|
offsets[idx] = pdf.length;
|
||
|
|
pdf += `${idx + 1} 0 obj\n${body}\nendobj\n`;
|
||
|
|
});
|
||
|
|
|
||
|
|
const xrefOffset = pdf.length;
|
||
|
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||
|
|
pdf += "0000000000 65535 f \n";
|
||
|
|
offsets.forEach((off) => {
|
||
|
|
pdf += `${String(off).padStart(10, "0")} 00000 n \n`;
|
||
|
|
});
|
||
|
|
pdf +=
|
||
|
|
`trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\n` +
|
||
|
|
`startxref\n${xrefOffset}\n%%EOF`;
|
||
|
|
|
||
|
|
return this._latin1Bytes(pdf);
|
||
|
|
},
|
||
|
|
|
||
|
|
_contentStream(lines, x, yTop, font, leading) {
|
||
|
|
let s = `BT\n/F1 ${font} Tf\n${leading} TL\n${x} ${yTop} Td\n`;
|
||
|
|
lines.forEach((line, i) => {
|
||
|
|
if (i > 0) s += "T*\n";
|
||
|
|
s += `(${this._escape(line)}) Tj\n`;
|
||
|
|
});
|
||
|
|
s += "ET";
|
||
|
|
return s;
|
||
|
|
},
|
||
|
|
|
||
|
|
_wrap(text, width) {
|
||
|
|
const out = [];
|
||
|
|
(text || "").split(/\r?\n/).forEach((para) => {
|
||
|
|
if (para.length <= width) { out.push(para); return; }
|
||
|
|
let line = "";
|
||
|
|
para.split(/\s+/).forEach((word) => {
|
||
|
|
if ((line + " " + word).trim().length > width) {
|
||
|
|
if (line) out.push(line);
|
||
|
|
// sehr lange Wörter hart umbrechen
|
||
|
|
while (word.length > width) { out.push(word.slice(0, width)); word = word.slice(width); }
|
||
|
|
line = word;
|
||
|
|
} else {
|
||
|
|
line = line ? `${line} ${word}` : word;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
if (line) out.push(line);
|
||
|
|
});
|
||
|
|
return out;
|
||
|
|
},
|
||
|
|
|
||
|
|
_escape(s) {
|
||
|
|
return this._toLatin1(s).replace(/[\\()]/g, "\\$&");
|
||
|
|
},
|
||
|
|
|
||
|
|
_toLatin1(s) {
|
||
|
|
// Zeichen außerhalb Latin-1 durch '?' ersetzen.
|
||
|
|
return (s || "").replace(/[^\x00-\xFF]/g, "?");
|
||
|
|
},
|
||
|
|
|
||
|
|
_latin1Bytes(str) {
|
||
|
|
const bytes = new Uint8Array(str.length);
|
||
|
|
for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff;
|
||
|
|
return bytes;
|
||
|
|
},
|
||
|
|
|
||
|
|
_fmtDate(d) {
|
||
|
|
try {
|
||
|
|
return new Intl.DateTimeFormat("de-DE", {
|
||
|
|
dateStyle: "medium", timeStyle: "short",
|
||
|
|
}).format(d);
|
||
|
|
} catch (_) {
|
||
|
|
return String(d);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
if (typeof module !== "undefined") module.exports = { Pdf };
|