Files

471 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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())}`;
}