/** * 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())}`; }