Übergabe von E-Mails als PDF mit eingebetteten Anlagen
This commit is contained in:
213
lib/mailpdf.js
Normal file
213
lib/mailpdf.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* mailpdf.js - E-Mail als lesbares PDF aufbereiten.
|
||||
*
|
||||
* Aufbau: Betreff als Überschrift, darunter ein grau hinterlegter
|
||||
* Kopfblock mit Konto, Absender, Empfängern, Datum und Anlagen, dann der
|
||||
* Nachrichtentext.
|
||||
*
|
||||
* Eingebettet werden die vollständige Originalnachricht als .eml sowie
|
||||
* auf Wunsch alle Anhänge im Original.
|
||||
*
|
||||
* BEWUSSTE EINSCHRÄNKUNG: HTML-Nachrichten werden als Text gesetzt, nicht
|
||||
* originalgetreu dargestellt. Eine Erweiterung hat keine
|
||||
* Darstellungsmaschine für HTML, und Thunderbird bietet Erweiterungen
|
||||
* keine Druckfunktion an - die tabs-Schnittstelle kennt in Manifest V3
|
||||
* kein saveAsPDF.
|
||||
*
|
||||
* Deshalb steckt das Original mit im PDF. Wer die gestaltete Fassung
|
||||
* braucht, öffnet den Anhang.
|
||||
*/
|
||||
|
||||
import { PdfDokument, textbreite, umbrechen, mm, FORMATE }
|
||||
from "./pdf.js";
|
||||
|
||||
const GRAU_TEXT = [0.35, 0.35, 0.35];
|
||||
const GRAU_FLAECHE = 0.95; // etwa 5 Prozent
|
||||
const INNEN = 8; // Polster im Kopfblock
|
||||
|
||||
/**
|
||||
* @param {object} daten
|
||||
* betreff, konto, von, an, kopie, datum Zeichenketten
|
||||
* text Nachrichtentext als Text
|
||||
* anhaenge [{name, groesse}] für die Aufstellung
|
||||
* dateien [{name, daten, mime, beschreibung}] wird eingebettet
|
||||
* @param {object} opt
|
||||
* schrift Grundschriftgröße in Punkten, Vorgabe 11
|
||||
* format Papierformat: a4, a5, letter, legal
|
||||
* rand {links, rechts, oben, unten} in Millimetern
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function baueMailPdf(daten, opt = {}) {
|
||||
const basis = begrenzen(Number(opt.schrift) || 11, 7, 16);
|
||||
// Die Überschrift wächst schwächer mit als der Fließtext. Sonst nimmt
|
||||
// sie bei größerer Grundschrift zu viel Raum und läuft über zwei
|
||||
// Zeilen, bevor der Inhalt überhaupt beginnt.
|
||||
const ueberschrift = basis + 2;
|
||||
const klein = Math.max(7, basis - 1.5);
|
||||
|
||||
const papier = FORMATE[String(opt.format || "a4").toLowerCase()]
|
||||
|| FORMATE.a4;
|
||||
const r = opt.rand || {};
|
||||
|
||||
// Die Ränder werden begrenzt, damit kein Dokument mit negativer
|
||||
// Textbreite entsteht - eine Zahl aus einem Eingabefeld kann alles
|
||||
// sein, und ein unbrauchbares PDF wäre schwer zu deuten.
|
||||
const grenze = (wert, vorgabe) =>
|
||||
Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe));
|
||||
|
||||
const d = new PdfDokument({
|
||||
titel: daten.betreff || "E-Mail",
|
||||
autor: daten.von || "",
|
||||
betreff: daten.betreff || "",
|
||||
breite: mm(papier.breite),
|
||||
hoehe: mm(papier.hoehe),
|
||||
randLinks: mm(grenze(r.links ?? 20, 20)),
|
||||
randRechts: mm(grenze(r.rechts ?? 10, 10)),
|
||||
randOben: mm(grenze(r.oben ?? 10, 10)),
|
||||
randUnten: mm(grenze(r.unten ?? 10, 10))
|
||||
});
|
||||
d.neueSeite();
|
||||
|
||||
// -- Überschrift
|
||||
d.absatz(daten.betreff || "(ohne Betreff)", {
|
||||
groesse: ueberschrift, fett: true, abstand: 1.25
|
||||
});
|
||||
d.abstand(6);
|
||||
|
||||
// -- Kopfblock
|
||||
kopfblock(d, daten, klein);
|
||||
|
||||
d.abstand(12);
|
||||
|
||||
// -- Inhalt
|
||||
const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd();
|
||||
if (text) {
|
||||
for (const zeile of text.split("\n")) {
|
||||
// Zitierte Abschnitte grau und eingerückt. Bei langen Verläufen
|
||||
// sieht man so auf einen Blick, was neu ist.
|
||||
const zitat = /^\s*>/.test(zeile);
|
||||
d.absatz(zeile || " ", {
|
||||
groesse: basis,
|
||||
abstand: 1.35,
|
||||
farbe: zitat ? GRAU_TEXT : null,
|
||||
einzug: zitat ? 10 : 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
d.absatz("(kein Textinhalt - siehe eingebettete Originalnachricht)",
|
||||
{ groesse: basis, farbe: GRAU_TEXT });
|
||||
}
|
||||
|
||||
// -- Fussbereich
|
||||
d.abstand(10);
|
||||
d.linie();
|
||||
d.absatz("Erzeugt aus der Originalnachricht, die diesem PDF als Datei "
|
||||
+ "beiliegt. HTML-Nachrichten sind hier als Text gesetzt; die "
|
||||
+ "gestaltete Fassung enthält der Anhang.",
|
||||
{ groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT,
|
||||
abstand: 1.3 });
|
||||
|
||||
for (const f of daten.dateien ?? []) {
|
||||
d.anhang(f.name, f.daten, {
|
||||
beschreibung: f.beschreibung || "",
|
||||
mime: f.mime || "application/octet-stream"
|
||||
});
|
||||
}
|
||||
|
||||
return d.bauen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopfblock mit ausgerichteten Beschriftungen auf grauem Grund.
|
||||
*
|
||||
* Die Höhe muss VOR dem Zeichnen feststehen: Die Fläche wird zuerst
|
||||
* gesetzt, sonst läge sie über dem Text - im Inhaltsstrom gilt die
|
||||
* Reihenfolge. Deshalb wird der Umbruch hier vorweg berechnet und danach
|
||||
* nur noch ausgegeben.
|
||||
*/
|
||||
function kopfblock(d, daten, groesse) {
|
||||
const zeilenhoehe = groesse * 1.45;
|
||||
|
||||
const felder = [];
|
||||
const nimm = (name, wert) => {
|
||||
if (wert && String(wert).trim()) {
|
||||
felder.push([name, [String(wert).trim()]]);
|
||||
}
|
||||
};
|
||||
nimm("Konto", daten.konto);
|
||||
nimm("Von", daten.von);
|
||||
nimm("An", daten.an);
|
||||
nimm("Kopie", daten.kopie);
|
||||
nimm("Datum", daten.datum);
|
||||
|
||||
if (daten.anhaenge?.length) {
|
||||
felder.push(["Anlagen", daten.anhaenge.map((a) => {
|
||||
const g = a.groesse ? ` (${lesbareGroesse(a.groesse)})` : "";
|
||||
return `- ${a.name}${g}`;
|
||||
})]);
|
||||
}
|
||||
if (!felder.length) return;
|
||||
|
||||
// Spaltenbreite aus der breitesten Beschriftung. So beginnen alle Werte
|
||||
// in derselben Spalte, ohne dass eine feste Breite geraten werden muss.
|
||||
let spalte = 0;
|
||||
for (const [name] of felder) {
|
||||
spalte = Math.max(spalte, textbreite(name, groesse, true));
|
||||
}
|
||||
spalte += 14;
|
||||
|
||||
const wertBreite = d.innenbreite - 2 * INNEN - spalte;
|
||||
|
||||
// Umbruch vorweg berechnen
|
||||
const ausgabe = [];
|
||||
for (const [name, werte] of felder) {
|
||||
const zeilen = [];
|
||||
for (const w of werte) {
|
||||
for (const z of umbrechen(w, wertBreite, groesse)) zeilen.push(z);
|
||||
}
|
||||
ausgabe.push([name, zeilen]);
|
||||
}
|
||||
const anzahl = ausgabe.reduce((n, [, z]) => n + z.length, 0);
|
||||
const hoehe = anzahl * zeilenhoehe + 2 * INNEN;
|
||||
|
||||
d.platzPruefen(hoehe);
|
||||
|
||||
const oben = d.y;
|
||||
d.rechteck(d.rand, oben - hoehe, d.innenbreite, hoehe, GRAU_FLAECHE);
|
||||
|
||||
let y = oben - INNEN;
|
||||
for (const [name, zeilen] of ausgabe) {
|
||||
for (const [i, zeile] of zeilen.entries()) {
|
||||
const grundlinie = y - groesse;
|
||||
if (i === 0) {
|
||||
d.textAn(d.rand + INNEN, grundlinie, name,
|
||||
{ groesse, fett: true, farbe: GRAU_TEXT });
|
||||
}
|
||||
d.textAn(d.rand + INNEN + spalte, grundlinie, zeile, { groesse });
|
||||
y -= zeilenhoehe;
|
||||
}
|
||||
}
|
||||
d.y = oben - hoehe;
|
||||
}
|
||||
|
||||
function begrenzen(wert, min, max) {
|
||||
return Math.min(max, Math.max(min, wert));
|
||||
}
|
||||
|
||||
export function lesbareGroesse(bytes) {
|
||||
const b = Number(bytes) || 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empfängerliste in eine Zeile überführen.
|
||||
*
|
||||
* Thunderbird liefert je nach Version ein Feld oder eine Zeichenkette.
|
||||
*/
|
||||
export function alsEmpfaenger(wert) {
|
||||
if (!wert) return "";
|
||||
if (Array.isArray(wert)) return wert.filter(Boolean).join(", ");
|
||||
return String(wert);
|
||||
}
|
||||
Reference in New Issue
Block a user