Files
2026-10-07 14:06:11 +00:00

170 lines
7.4 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.
// 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)
}
}