Files
thunderbird-paperless-conne…/lib/mailpdf.js

287 lines
9.1 KiB
JavaScript

/**
* 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;
}
/**
* Beliebigen Text als PDF setzen, mit der Ursprungsdatei als Anhang.
*
* Für Anhänge, die Text enthalten - HTML, Nur-Text, eingebettete
* Nachrichten. Formate wie DOCX oder XLSX lassen sich hier NICHT
* umwandeln; dafür bräuchte es eine Darstellungsmaschine.
*/
export function baueTextPdf(daten, opt = {}) {
const basis = begrenzen(Number(opt.schrift) || 11, 7, 16);
const papier = FORMATE[String(opt.format || "a4").toLowerCase()]
|| FORMATE.a4;
const r = opt.rand || {};
const grenze = (wert, vorgabe) =>
Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe));
const d = new PdfDokument({
titel: daten.titel || "Dokument",
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();
d.absatz(daten.titel || "Dokument",
{ groesse: basis + 2, fett: true, abstand: 1.25 });
d.abstand(6);
d.linie();
d.abstand(8);
const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd();
if (text) {
for (const zeile of text.split("\n")) {
d.absatz(zeile || " ", { groesse: basis, abstand: 1.35 });
}
} else {
d.absatz("(kein Textinhalt - siehe eingebettete Originaldatei)",
{ groesse: basis, farbe: GRAU_TEXT });
}
d.abstand(10);
d.linie();
d.absatz("Aus der Originaldatei erzeugt, die diesem PDF beiliegt.",
{ groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT });
for (const f of daten.dateien ?? []) {
d.anhang(f.name, f.daten, {
beschreibung: f.beschreibung || "",
mime: f.mime || "application/octet-stream"
});
}
return d.bauen();
}
/**
* Lässt sich diese Datei als PDF aufbereiten?
*
* Nur was Text enthält. Bei DOCX oder XLSX müsste die Erweiterung das
* Format auswerten und setzen - dafür gibt es keine Grundlage.
*/
export function umwandelbar(mime, name = "") {
const m = String(mime || "").toLowerCase();
if (/^text\//.test(m)) return true;
if (m === "message/rfc822") return true;
if (["application/json", "application/xml", "application/xhtml+xml",
"application/rtf"].includes(m)) return true;
// Manche Server melden alles als octet-stream. Dann entscheidet die
// Endung.
return /\.(txt|htm|html|xml|json|csv|md|log|eml)$/i.test(String(name));
}
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);
}