// Minimaler PDF-Writer (Buchungskalender N2): Helvetica und Helvetica-Bold, WinAnsiEncoding, keine Abhaengigkeit. // Koordinaten: Punkt, Ursprung oben links (y waechst nach unten). Ausgabe ist deterministisch. // Helvetica-AFM-Breiten (1/1000 em) fuer die WinAnsi-Codes 32..255 (0 = nicht definiert, wird als '?' geschrieben). const W_NORMAL = [ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, ] const W_FETT = [ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, ] // CP1252-Zeichen oberhalb von U+007F im Bereich 0x80..0x9F. const CP1252 = new Map(Object.entries({ '€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, '‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, '˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f, })) /** Ein Zeichen als WinAnsi-Code; Unbekanntes wird '?'. */ function code(ch) { if (ch === '−') return 0x2d const c = ch.codePointAt(0) if (c === 0x0a || c === 0x0d || c === 0x09) return 0x20 if (c >= 32 && c < 127) return c if (c >= 160 && c <= 255) return c return CP1252.get(ch) ?? 0x3f } const bytes = (str) => Array.from(String(str ?? ''), code) const zahl = (n) => String(Math.round(n * 100) / 100) export class PdfDoc { constructor({ breite = 595.28, hoehe = 841.89 } = {}) { this.b = breite this.h = hoehe this.seiten = [] this.aktuell = null this.seite() } /** Neue Seite beginnen (und zur aktuellen machen). */ seite() { this.seiten.push([]) this.aktuell = this.seiten.length - 1 return this.aktuell } /** Auf eine bestehende Seite zurueckschalten (z. B. fuer Fusszeilen). */ aufSeite(n) { if (!(n >= 0 && n < this.seiten.length)) throw new RangeError('Seite existiert nicht') this.aktuell = n } get seitenzahl() { return this.seiten.length } breite(str, groesse = 10, fett = false) { const w = fett ? W_FETT : W_NORMAL let sum = 0 for (const c of bytes(str)) sum += w[c - 32] || w[0x3f - 32] return (sum * groesse) / 1000 } /** Text; y = Grundlinie von oben, x = linker Rand (bei rechts: rechter Rand). */ text(x, y, str, { groesse = 10, fett = false, rechts = false } = {}) { const bs = bytes(str) if (bs.length === 0) return const px = rechts ? x - this.breite(str, groesse, fett) : x let s = '' for (const c of bs) s += c === 0x5c || c === 0x28 || c === 0x29 ? `\\${String.fromCharCode(c)}` : String.fromCharCode(c) this.seiten[this.aktuell].push(`BT /${fett ? 'F2' : 'F1'} ${zahl(groesse)} Tf ${zahl(px)} ${zahl(this.h - y)} Td (${s}) Tj ET`) } linie(x1, y1, x2, y2, { dicke = 0.5 } = {}) { this.seiten[this.aktuell].push(`${zahl(dicke)} w ${zahl(x1)} ${zahl(this.h - y1)} m ${zahl(x2)} ${zahl(this.h - y2)} l S`) } /** Bricht an Leerzeichen um; ueberlange Woerter werden hart getrennt. */ umbrechen(str, maxBreite, groesse = 10, fett = false) { const zeilen = [] for (const absatz of String(str ?? '').split(/\r?\n/)) { let zeile = '' for (let wort of absatz.split(/ +/).filter(Boolean)) { const probe = zeile ? `${zeile} ${wort}` : wort if (this.breite(probe, groesse, fett) <= maxBreite) { zeile = probe continue } if (zeile) zeilen.push(zeile) zeile = '' while (this.breite(wort, groesse, fett) > maxBreite && wort.length > 1) { let n = wort.length - 1 while (n > 1 && this.breite(wort.slice(0, n), groesse, fett) > maxBreite) n-- zeilen.push(wort.slice(0, n)) wort = wort.slice(n) } zeile = wort } zeilen.push(zeile) } return zeilen } toBuffer() { const teile = [] const offs = [] let len = 0 const add = (buf) => { const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf, 'latin1') teile.push(b) len += b.length } const obj = (nr, inhalt) => { offs[nr] = len add(`${nr} 0 obj\n`) add(inhalt) add('\nendobj\n') } const n = this.seiten.length add('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n') obj(1, '<< /Type /Catalog /Pages 2 0 R >>') const kids = this.seiten.map((_, i) => `${5 + i * 2} 0 R`).join(' ') obj(2, `<< /Type /Pages /Kids [${kids}] /Count ${n} >>`) obj(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>') obj(4, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>') this.seiten.forEach((ops, i) => { const pn = 5 + i * 2 obj(pn, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${zahl(this.b)} ${zahl(this.h)}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${pn + 1} 0 R >>`) const strom = Buffer.from(ops.join('\n') + '\n', 'latin1') offs[pn + 1] = len add(`${pn + 1} 0 obj\n<< /Length ${strom.length} >>\nstream\n`) add(strom) add('endstream\nendobj\n') }) const total = 5 + n * 2 const xref = len add(`xref\n0 ${total}\n0000000000 65535 f \n`) for (let i = 1; i < total; i++) add(`${String(offs[i]).padStart(10, '0')} 00000 n \n`) add(`trailer\n<< /Size ${total} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`) return Buffer.concat(teile) } }