2026-06-03 11:15:59 +00:00
|
|
|
|
// 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, "\\$&");
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-16 08:50:31 +00:00
|
|
|
|
// Unicode-Sonderzeichen, die NICHT in Latin-1, aber in WinAnsiEncoding liegen
|
|
|
|
|
|
// (0x80–0x9F). Da die Schrift /WinAnsiEncoding nutzt und Bytes per charCodeAt&0xFF
|
|
|
|
|
|
// geschrieben werden, rendern diese so als echte Typografie-Zeichen statt als '?'.
|
|
|
|
|
|
_WINANSI: {
|
|
|
|
|
|
"€": 0x80, "‚": 0x82, "ƒ": 0x83, "„": 0x84, "…": 0x85,
|
|
|
|
|
|
"†": 0x86, "‡": 0x87, "ˆ": 0x88, "‰": 0x89, "Š": 0x8A,
|
|
|
|
|
|
"‹": 0x8B, "Œ": 0x8C, "Ž": 0x8E, "‘": 0x91, "’": 0x92,
|
|
|
|
|
|
"“": 0x93, "”": 0x94, "•": 0x95, "–": 0x96, "—": 0x97,
|
|
|
|
|
|
"˜": 0x98, "™": 0x99, "š": 0x9A, "›": 0x9B, "œ": 0x9C,
|
|
|
|
|
|
"ž": 0x9E, "Ÿ": 0x9F,
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-03 11:15:59 +00:00
|
|
|
|
_toLatin1(s) {
|
2026-06-16 08:50:31 +00:00
|
|
|
|
let out = "";
|
|
|
|
|
|
for (const ch of (s || "")) {
|
|
|
|
|
|
const cp = ch.codePointAt(0);
|
|
|
|
|
|
if (cp <= 0xff) out += ch; // Latin-1 direkt
|
|
|
|
|
|
else if (this._WINANSI[ch] != null) out += String.fromCharCode(this._WINANSI[ch]);
|
|
|
|
|
|
else out += "?"; // nicht darstellbar
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
2026-06-03 11:15:59 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_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 };
|