Übergabe von E-Mails als PDF mit eingebetteten Anlagen
This commit is contained in:
470
lib/pdf.js
Normal file
470
lib/pdf.js
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* pdf.js - Schlanker PDF-Erzeuger für Textseiten mit eingebetteten Dateien.
|
||||
*
|
||||
* Warum selbst geschrieben statt einer fertigen Bibliothek: Eine
|
||||
* Erweiterung soll nichts nachladen, und ein mitgeliefertes pdf-lib wäre
|
||||
* rund ein Megabyte für einen Bruchteil seiner Fähigkeiten. Hier werden
|
||||
* nur Text und Anhänge gebraucht.
|
||||
*
|
||||
* Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und
|
||||
* Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht
|
||||
* eingebettet werden.
|
||||
*
|
||||
* AUSDRÜCKLICH KEIN PDF/A. Dafür wären eingebettete Schriftschnitte, ein
|
||||
* Farbprofil und XMP-Metadaten nötig - und vor allem eine Prüfung mit
|
||||
* veraPDF, die hier niemand durchführen kann. Ein Dokument als PDF/A zu
|
||||
* bezeichnen, ohne es geprüft zu haben, wäre eine leere Behauptung.
|
||||
*/
|
||||
|
||||
/** Millimeter in Punkte. Ein Punkt ist 1/72 Zoll. */
|
||||
export function mm(wert) {
|
||||
return (Number(wert) || 0) * 72 / 25.4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Papierformate in Millimetern.
|
||||
*
|
||||
* A4 ist ausserhalb Nordamerikas der Regelfall, Letter und Legal dort.
|
||||
* A5 ist selten, aber bei kurzen Nachrichten sparsam im Ausdruck.
|
||||
*/
|
||||
export const FORMATE = {
|
||||
a4: { name: "A4", breite: 210, hoehe: 297 },
|
||||
a5: { name: "A5", breite: 148, hoehe: 210 },
|
||||
letter: { name: "Letter", breite: 215.9, hoehe: 279.4 },
|
||||
legal: { name: "Legal", breite: 215.9, hoehe: 355.6 }
|
||||
};
|
||||
|
||||
// Rückwärtskompatible Vorgaben in Punkten
|
||||
export const SEITE_BREITE = mm(FORMATE.a4.breite);
|
||||
export const SEITE_HOEHE = mm(FORMATE.a4.hoehe);
|
||||
|
||||
// Zeichenbreiten der Standardschriften, in Tausendstel der Schriftgröße.
|
||||
// Aus den AFM-Dateien von Adobe. Sie werden nur zum Umbrechen gebraucht -
|
||||
// eine kleine Abweichung verschiebt den Umbruch, verfälscht aber nichts.
|
||||
const BREITE_HELVETICA = {
|
||||
" ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667,
|
||||
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
|
||||
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
|
||||
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, _: 556,
|
||||
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
|
||||
A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
|
||||
J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
|
||||
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
|
||||
a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222,
|
||||
j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333,
|
||||
s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500,
|
||||
"0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556,
|
||||
"7": 556, "8": 556, "9": 556,
|
||||
"ä": 556, "ö": 556, "ü": 556, "Ä": 667, "Ö": 778, "Ü": 722, "ß": 556,
|
||||
"é": 556, "è": 556, "à": 556, "ç": 500, "–": 556, "—": 1000, "€": 556
|
||||
};
|
||||
|
||||
const BREITE_FETT = {
|
||||
...BREITE_HELVETICA,
|
||||
A: 722, B: 722, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
|
||||
J: 556, K: 722, L: 611, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
|
||||
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
|
||||
a: 556, b: 611, c: 556, d: 611, e: 556, f: 333, g: 611, h: 611, i: 278,
|
||||
j: 278, k: 556, l: 278, m: 889, n: 611, o: 611, p: 611, q: 611, r: 389,
|
||||
s: 556, t: 333, u: 611, v: 556, w: 778, x: 556, y: 556, z: 500,
|
||||
"'": 238, "-": 333, ":": 333, ";": 333, "(": 333, ")": 333
|
||||
};
|
||||
|
||||
const BREITE_ERSATZ = 556;
|
||||
|
||||
/**
|
||||
* Zeichen, die in WinAnsiEncoding an anderer Stelle stehen als in
|
||||
* Latin-1. PDF-Betrachter erwarten diese Belegung; ohne die Umsetzung
|
||||
* erschiene etwa das Eurozeichen als Steuerzeichen.
|
||||
*/
|
||||
const WINANSI_SONDER = {
|
||||
"\u20AC": 0x80, "\u201A": 0x82, "\u0192": 0x83, "\u201E": 0x84,
|
||||
"\u2026": 0x85, "\u2020": 0x86, "\u2021": 0x87, "\u02C6": 0x88,
|
||||
"\u2030": 0x89, "\u0160": 0x8A, "\u2039": 0x8B, "\u0152": 0x8C,
|
||||
"\u017D": 0x8E, "\u2018": 0x91, "\u2019": 0x92, "\u201C": 0x93,
|
||||
"\u201D": 0x94, "\u2022": 0x95, "\u2013": 0x96, "\u2014": 0x97,
|
||||
"\u02DC": 0x98, "\u2122": 0x99, "\u0161": 0x9A, "\u203A": 0x9B,
|
||||
"\u0153": 0x9C, "\u017E": 0x9E, "\u0178": 0x9F
|
||||
};
|
||||
|
||||
/** Breite einer Zeichenkette in Punkten. */
|
||||
export function textbreite(text, groesse, fett = false) {
|
||||
const tabelle = fett ? BREITE_FETT : BREITE_HELVETICA;
|
||||
let summe = 0;
|
||||
for (const z of String(text)) {
|
||||
summe += tabelle[z] ?? BREITE_ERSATZ;
|
||||
}
|
||||
return (summe * groesse) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichenkette in eine PDF-Literalzeichenkette überführen.
|
||||
*
|
||||
* Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt.
|
||||
* Das betrifft in der Praxis fast nur Emojis - dafür wäre eine
|
||||
* eingebettete Schrift nötig.
|
||||
*/
|
||||
function pdfText(text) {
|
||||
const bytes = [];
|
||||
for (const z of String(text)) {
|
||||
let code;
|
||||
if (WINANSI_SONDER[z] !== undefined) {
|
||||
code = WINANSI_SONDER[z];
|
||||
} else {
|
||||
const cp = z.codePointAt(0);
|
||||
code = cp <= 0xFF ? cp : 0x3F; // Fragezeichen
|
||||
}
|
||||
if (code === 0x28 || code === 0x29 || code === 0x5C) {
|
||||
bytes.push(0x5C); // Rückstrich voranstellen
|
||||
}
|
||||
bytes.push(code);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Text in Zeilen umbrechen, die in die angegebene Breite passen. */
|
||||
export function umbrechen(text, breite, groesse, fett = false) {
|
||||
const zeilen = [];
|
||||
for (const absatz of String(text ?? "").split(/\r?\n/)) {
|
||||
if (!absatz.trim()) {
|
||||
zeilen.push("");
|
||||
continue;
|
||||
}
|
||||
let zeile = "";
|
||||
for (const wort of absatz.split(/ +/)) {
|
||||
const versuch = zeile ? `${zeile} ${wort}` : wort;
|
||||
if (textbreite(versuch, groesse, fett) <= breite || !zeile) {
|
||||
// Ein einzelnes zu langes Wort wird hart getrennt - sonst liefe
|
||||
// es über den Rand hinaus.
|
||||
if (!zeile && textbreite(wort, groesse, fett) > breite) {
|
||||
let rest = wort;
|
||||
while (textbreite(rest, groesse, fett) > breite) {
|
||||
let n = 1;
|
||||
while (n < rest.length
|
||||
&& textbreite(rest.slice(0, n + 1), groesse, fett) <= breite) {
|
||||
n += 1;
|
||||
}
|
||||
zeilen.push(rest.slice(0, n));
|
||||
rest = rest.slice(n);
|
||||
}
|
||||
zeile = rest;
|
||||
} else {
|
||||
zeile = versuch;
|
||||
}
|
||||
} else {
|
||||
zeilen.push(zeile);
|
||||
zeile = wort;
|
||||
}
|
||||
}
|
||||
zeilen.push(zeile);
|
||||
}
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Aufbau
|
||||
|
||||
export class PdfDokument {
|
||||
/**
|
||||
* Seitengröße und Ränder werden in PUNKTEN erwartet. Für die Umrechnung
|
||||
* aus Millimetern gibt es mm().
|
||||
*/
|
||||
constructor({ titel = "", autor = "", betreff = "",
|
||||
breite = SEITE_BREITE, hoehe = SEITE_HOEHE,
|
||||
randLinks = mm(20), randRechts = mm(10),
|
||||
randOben = mm(10), randUnten = mm(10) } = {}) {
|
||||
this.titel = titel;
|
||||
this.autor = autor;
|
||||
this.betreff = betreff;
|
||||
this.breite = breite;
|
||||
this.hoehe = hoehe;
|
||||
this.randLinks = randLinks;
|
||||
this.randRechts = randRechts;
|
||||
this.randOben = randOben;
|
||||
this.randUnten = randUnten;
|
||||
this.seiten = []; // je Seite ein Feld von Byte-Stücken
|
||||
this.anhaenge = []; // {name, daten, beschreibung, mime}
|
||||
this.aktuell = null;
|
||||
this.y = 0;
|
||||
}
|
||||
|
||||
/** Linker Rand. Der Name bleibt kurz, weil er oft gebraucht wird. */
|
||||
get rand() {
|
||||
return this.randLinks;
|
||||
}
|
||||
|
||||
get innenbreite() {
|
||||
return this.breite - this.randLinks - this.randRechts;
|
||||
}
|
||||
|
||||
neueSeite() {
|
||||
this.aktuell = [];
|
||||
this.seiten.push(this.aktuell);
|
||||
this.y = this.hoehe - this.randOben;
|
||||
return this.aktuell;
|
||||
}
|
||||
|
||||
/** Platz für die nächsten Zeilen sichern, notfalls umblättern. */
|
||||
platzPruefen(hoehe) {
|
||||
if (!this.aktuell) this.neueSeite();
|
||||
if (this.y - hoehe < this.randUnten) this.neueSeite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Textzeile setzen. Gibt die neue Schreibhöhe zurück.
|
||||
*/
|
||||
zeile(text, { groesse = 10, fett = false, einzug = 0,
|
||||
abstand = 1.35, farbe = null } = {}) {
|
||||
const hoehe = groesse * abstand;
|
||||
this.platzPruefen(hoehe);
|
||||
const x = this.rand + einzug;
|
||||
const teile = [];
|
||||
if (farbe) {
|
||||
teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
|
||||
}
|
||||
teile.push("BT\n");
|
||||
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
|
||||
teile.push(`1 0 0 1 ${x.toFixed(2)} ${(this.y - groesse).toFixed(2)} Tm\n`);
|
||||
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
|
||||
this.aktuell.push(new Uint8Array([0x28])); // (
|
||||
this.aktuell.push(new Uint8Array(pdfText(text)));
|
||||
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
|
||||
if (farbe) {
|
||||
this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
|
||||
}
|
||||
this.y -= hoehe;
|
||||
return this.y;
|
||||
}
|
||||
|
||||
/** Mehrere Zeilen mit automatischem Umbruch. */
|
||||
absatz(text, opt = {}) {
|
||||
const groesse = opt.groesse ?? 10;
|
||||
const einzug = opt.einzug ?? 0;
|
||||
const breite = this.innenbreite - einzug;
|
||||
for (const z of umbrechen(text, breite, groesse, opt.fett)) {
|
||||
this.zeile(z, opt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text an einer festen Stelle setzen, ohne die Schreibhöhe zu ändern.
|
||||
*
|
||||
* Wird für Spaltensatz gebraucht: Beschriftung und Wert stehen auf
|
||||
* derselben Grundlinie, aber an verschiedenen Stellen.
|
||||
*/
|
||||
textAn(x, grundlinie, text, { groesse = 10, fett = false,
|
||||
farbe = null } = {}) {
|
||||
if (!this.aktuell) this.neueSeite();
|
||||
const teile = [];
|
||||
if (farbe) teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
|
||||
teile.push("BT\n");
|
||||
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
|
||||
teile.push(`1 0 0 1 ${x.toFixed(2)} ${grundlinie.toFixed(2)} Tm\n`);
|
||||
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
|
||||
this.aktuell.push(new Uint8Array([0x28]));
|
||||
this.aktuell.push(new Uint8Array(pdfText(text)));
|
||||
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
|
||||
if (farbe) this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gefülltes Rechteck.
|
||||
*
|
||||
* Muss VOR dem Text gezeichnet werden - im Inhaltsstrom gilt die
|
||||
* Reihenfolge, Späteres liegt oben.
|
||||
*/
|
||||
rechteck(x, y, breite, hoehe, grau = 0.95) {
|
||||
if (!this.aktuell) this.neueSeite();
|
||||
const s = `${grau} g ${x.toFixed(2)} ${y.toFixed(2)} `
|
||||
+ `${breite.toFixed(2)} ${hoehe.toFixed(2)} re f 0 g\n`;
|
||||
this.aktuell.push(new TextEncoder().encode(s));
|
||||
}
|
||||
|
||||
/** Waagerechte Linie. */
|
||||
linie(dicke = 0.6, grau = 0.75) {
|
||||
this.platzPruefen(8);
|
||||
this.y -= 4;
|
||||
const s = `${grau} G ${dicke} w ${this.randLinks.toFixed(2)} `
|
||||
+ `${this.y.toFixed(2)} m `
|
||||
+ `${(this.breite - this.randRechts).toFixed(2)} `
|
||||
+ `${this.y.toFixed(2)} l S\n`;
|
||||
this.aktuell.push(new TextEncoder().encode(s));
|
||||
this.y -= 8;
|
||||
}
|
||||
|
||||
abstand(punkte) {
|
||||
this.platzPruefen(punkte);
|
||||
this.y -= punkte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei an das Dokument anhängen.
|
||||
*
|
||||
* daten muss ein Uint8Array sein. Der Name wird im Anhangsbereich des
|
||||
* Betrachters angezeigt.
|
||||
*/
|
||||
anhang(name, daten, { beschreibung = "", mime = "application/octet-stream" } = {}) {
|
||||
this.anhaenge.push({
|
||||
name: String(name),
|
||||
daten: daten instanceof Uint8Array ? daten : new Uint8Array(daten),
|
||||
beschreibung: String(beschreibung),
|
||||
mime: String(mime)
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Ausgabe
|
||||
|
||||
bauen() {
|
||||
if (!this.seiten.length) this.neueSeite();
|
||||
|
||||
const enc = (s) => new TextEncoder().encode(s);
|
||||
const objekte = []; // 1-basiert, Index 0 bleibt leer
|
||||
const neu = (inhalt) => { objekte.push(inhalt); return objekte.length; };
|
||||
|
||||
// Reihenfolge festlegen, damit Verweise bekannt sind
|
||||
const katalogNr = 1;
|
||||
const seitenbaumNr = 2;
|
||||
const infoNr = 3;
|
||||
const f1Nr = 4;
|
||||
const f2Nr = 5;
|
||||
objekte.push(null, null, null, null, null); // Plätze 1..5
|
||||
|
||||
// Seiteninhalte und Seitenobjekte
|
||||
const seitenNr = [];
|
||||
for (const stuecke of this.seiten) {
|
||||
const inhalt = verbinden(stuecke);
|
||||
const stromNr = neu({
|
||||
kopf: `<< /Length ${inhalt.length} >>`,
|
||||
strom: inhalt
|
||||
});
|
||||
const seiteNr = neu({
|
||||
kopf: `<< /Type /Page /Parent ${seitenbaumNr} 0 R `
|
||||
+ `/MediaBox [0 0 ${this.breite.toFixed(2)} `
|
||||
+ `${this.hoehe.toFixed(2)}] `
|
||||
+ `/Resources << /Font << /F1 ${f1Nr} 0 R /F2 ${f2Nr} 0 R >> >> `
|
||||
+ `/Contents ${stromNr} 0 R >>`
|
||||
});
|
||||
seitenNr.push(seiteNr);
|
||||
}
|
||||
|
||||
// Anhänge
|
||||
const namensPaare = [];
|
||||
for (const a of this.anhaenge) {
|
||||
const stromNr = neu({
|
||||
kopf: `<< /Type /EmbeddedFile /Subtype ${nameEscape(a.mime)} `
|
||||
+ `/Length ${a.daten.length} /Params << /Size ${a.daten.length} >> >>`,
|
||||
strom: a.daten
|
||||
});
|
||||
const specNr = neu({
|
||||
kopf: `<< /Type /Filespec /F (${roh(a.name)}) /UF (${roh(a.name)}) `
|
||||
+ (a.beschreibung ? `/Desc (${roh(a.beschreibung)}) ` : "")
|
||||
+ `/AFRelationship /Source /EF << /F ${stromNr} 0 R >> >>`
|
||||
});
|
||||
namensPaare.push(`(${roh(a.name)}) ${specNr} 0 R`);
|
||||
}
|
||||
|
||||
let namenNr = 0;
|
||||
if (namensPaare.length) {
|
||||
namenNr = neu({
|
||||
kopf: `<< /EmbeddedFiles << /Names [ ${namensPaare.join(" ")} ] >> >>`
|
||||
});
|
||||
}
|
||||
|
||||
// Feste Objekte nachtragen
|
||||
objekte[katalogNr - 1] = {
|
||||
kopf: `<< /Type /Catalog /Pages ${seitenbaumNr} 0 R`
|
||||
+ (namenNr ? ` /Names ${namenNr} 0 R /PageMode /UseAttachments` : "")
|
||||
+ " >>"
|
||||
};
|
||||
objekte[seitenbaumNr - 1] = {
|
||||
kopf: `<< /Type /Pages /Count ${seitenNr.length} `
|
||||
+ `/Kids [ ${seitenNr.map((n) => `${n} 0 R`).join(" ")} ] >>`
|
||||
};
|
||||
objekte[infoNr - 1] = {
|
||||
kopf: "<< " + [
|
||||
this.titel ? `/Title (${roh(this.titel)})` : "",
|
||||
this.autor ? `/Author (${roh(this.autor)})` : "",
|
||||
this.betreff ? `/Subject (${roh(this.betreff)})` : "",
|
||||
"/Producer (Paperless-Connector)",
|
||||
`/CreationDate (${pdfDatum(new Date())})`
|
||||
].filter(Boolean).join(" ") + " >>"
|
||||
};
|
||||
objekte[f1Nr - 1] = {
|
||||
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
|
||||
+ "/Encoding /WinAnsiEncoding >>"
|
||||
};
|
||||
objekte[f2Nr - 1] = {
|
||||
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold "
|
||||
+ "/Encoding /WinAnsiEncoding >>"
|
||||
};
|
||||
|
||||
// Zusammensetzen
|
||||
const teile = [enc("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")];
|
||||
let pos = teile[0].length;
|
||||
const versatz = [0];
|
||||
|
||||
for (let i = 0; i < objekte.length; i += 1) {
|
||||
const o = objekte[i];
|
||||
versatz.push(pos);
|
||||
const kopf = enc(`${i + 1} 0 obj\n${o.kopf}\n`);
|
||||
teile.push(kopf);
|
||||
pos += kopf.length;
|
||||
if (o.strom) {
|
||||
const a = enc("stream\n");
|
||||
teile.push(a, o.strom, enc("\nendstream\n"));
|
||||
pos += a.length + o.strom.length + 11;
|
||||
}
|
||||
const ende = enc("endobj\n");
|
||||
teile.push(ende);
|
||||
pos += ende.length;
|
||||
}
|
||||
|
||||
const xrefPos = pos;
|
||||
let xref = `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objekte.length; i += 1) {
|
||||
xref += `${String(versatz[i]).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
xref += `trailer\n<< /Size ${objekte.length + 1} `
|
||||
+ `/Root ${katalogNr} 0 R /Info ${infoNr} 0 R >>\n`
|
||||
+ `startxref\n${xrefPos}\n%%EOF\n`;
|
||||
teile.push(enc(xref));
|
||||
|
||||
return verbinden(teile);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Hilfsmittel
|
||||
|
||||
function verbinden(stuecke) {
|
||||
let laenge = 0;
|
||||
for (const s of stuecke) laenge += s.length;
|
||||
const aus = new Uint8Array(laenge);
|
||||
let p = 0;
|
||||
for (const s of stuecke) { aus.set(s, p); p += s.length; }
|
||||
return aus;
|
||||
}
|
||||
|
||||
/** Zeichenkette für eine PDF-Literalzeichenkette absichern. */
|
||||
function roh(text) {
|
||||
return String(text)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
// Zeichen ausserhalb von ASCII vermeiden - in Namen und Titeln reicht
|
||||
// das, und es erspart die Umsetzung nach WinAnsi an dieser Stelle.
|
||||
.replace(/[^\x20-\x7E]/g, (z) => ({
|
||||
"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
|
||||
"ß": "ss"
|
||||
}[z] ?? "?"));
|
||||
}
|
||||
|
||||
/** Namen im PDF-Syntax absichern, etwa für Medientypen. */
|
||||
function nameEscape(text) {
|
||||
return "/" + String(text).replace(/[^A-Za-z0-9._-]/g,
|
||||
(z) => "#" + z.charCodeAt(0).toString(16).padStart(2, "0"));
|
||||
}
|
||||
|
||||
function pdfDatum(d) {
|
||||
const z = (n) => String(n).padStart(2, "0");
|
||||
return `D:${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`
|
||||
+ `${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`;
|
||||
}
|
||||
Reference in New Issue
Block a user