Buchungskalender: PDF-Writer und Rechnungs-PDF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7b5b98187
commit
118be44847
3 files changed
+413
No files matched your search
@@ -0,0 +1,169 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Rechnungs-PDF aus dem unveraenderlichen Schnappschuss ukRechnungen.inhalt (N2). Deterministisch, A4.
|
||||
import { PdfDoc } from './pdf.js'
|
||||
import { formatEuro } from './preis.js'
|
||||
import { formatTag } from './datum.js'
|
||||
|
||||
const L = 56
|
||||
const R = 595.28 - 56
|
||||
const UNTEN = 745 // Inhalt endet hier, darunter Fusszeile
|
||||
const tag = (t) => (/^\d{4}-\d{2}-\d{2}$/.test(String(t ?? '')) ? formatTag(t) : String(t ?? ''))
|
||||
const nz = (s) => String(s ?? '').trim()
|
||||
const ortZeile = (o) => [nz(o?.plz), nz(o?.ort)].filter(Boolean).join(' ')
|
||||
|
||||
export function rechnungPdf(inhalt, { art = 'rechnung', nummer = '', datum = '' } = {}) {
|
||||
const i = inhalt || {}
|
||||
const a = i.aussteller || {}
|
||||
const e = i.empfaenger || {}
|
||||
const storno = art === 'storno'
|
||||
const klein = (i.steuerart || i.summen?.steuerart) === 'kleinunternehmer'
|
||||
const doc = new PdfDoc()
|
||||
let y
|
||||
|
||||
// Absenderzeile (klein)
|
||||
const absender = [nz(a.firma), nz(a.strasse), ortZeile(a)].filter(Boolean).join(' · ')
|
||||
doc.text(L, 60, absender, { groesse: 7.5 })
|
||||
doc.linie(L, 64, L + 250, 64, { dicke: 0.3 })
|
||||
|
||||
// Empfaenger
|
||||
const adr = [nz(e.firma), nz(e.name), nz(e.strasse), ortZeile(e), nz(e.land)].filter(Boolean)
|
||||
adr.forEach((z, k) => doc.text(L, 82 + k * 13, z, { groesse: 10 }))
|
||||
|
||||
// Rechts: Nummer, Datum, Leistungszeitraum
|
||||
const kopf = [
|
||||
[storno ? 'Stornonummer' : 'Rechnungsnummer', nz(nummer)],
|
||||
['Datum', tag(datum)],
|
||||
['Leistungszeitraum', `${tag(i.von)} – ${tag(i.bis)}`],
|
||||
]
|
||||
if (storno && i.bezugNummer) kopf.splice(1, 0, ['Zu Rechnung', nz(i.bezugNummer)])
|
||||
kopf.forEach(([k, v], n) => {
|
||||
doc.text(350, 82 + n * 13, k, { groesse: 9 })
|
||||
doc.text(R, 82 + n * 13, v, { groesse: 9, fett: true, rechts: true })
|
||||
})
|
||||
|
||||
// Titel
|
||||
y = 190
|
||||
doc.text(L, y, storno ? `Stornorechnung zu ${nz(i.bezugNummer)}` : 'Rechnung', { groesse: 16, fett: true })
|
||||
y += 22
|
||||
const leistung = `Beherbergung ${nz(i.unterkunft?.name)}, ${tag(i.von)}–${tag(i.bis)} (${i.naechte} ${i.naechte === 1 ? 'Nacht' : 'Nächte'}, ${i.personen} ${i.personen === 1 ? 'Person' : 'Personen'})`
|
||||
for (const z of doc.umbrechen(leistung, R - L, 10)) {
|
||||
doc.text(L, y, z, { groesse: 10 })
|
||||
y += 13
|
||||
}
|
||||
y += 10
|
||||
|
||||
// Posten-Tabelle
|
||||
const cMenge = 318
|
||||
const cUst = 362
|
||||
const cEinzel = 450
|
||||
const cBetrag = R
|
||||
const textBreite = (klein ? cMenge - 30 : cUst - 40) - L - 20
|
||||
const kopfzeile = () => {
|
||||
doc.text(L, y, 'Leistung', { groesse: 9, fett: true })
|
||||
doc.text(cMenge, y, 'Menge', { groesse: 9, fett: true, rechts: true })
|
||||
if (!klein) doc.text(cUst, y, 'USt', { groesse: 9, fett: true, rechts: true })
|
||||
doc.text(cEinzel, y, 'Einzelpreis', { groesse: 9, fett: true, rechts: true })
|
||||
doc.text(cBetrag, y, 'Betrag', { groesse: 9, fett: true, rechts: true })
|
||||
doc.linie(L, y + 4, R, y + 4)
|
||||
y += 18
|
||||
}
|
||||
kopfzeile()
|
||||
for (const p of i.posten || []) {
|
||||
const zeilen = doc.umbrechen(nz(p.text), textBreite, 10)
|
||||
if (y + zeilen.length * 12 > UNTEN) {
|
||||
doc.seite()
|
||||
y = 70
|
||||
kopfzeile()
|
||||
}
|
||||
const betrag = Math.round(p.menge * p.einzelCent)
|
||||
zeilen.forEach((z, k) => doc.text(L, y + k * 12, z, { groesse: 10 }))
|
||||
doc.text(cMenge, y, String(p.menge), { groesse: 10, rechts: true })
|
||||
if (!klein) doc.text(cUst, y, `${p.ustProzent} %`, { groesse: 10, rechts: true })
|
||||
doc.text(cEinzel, y, formatEuro(p.einzelCent), { groesse: 10, rechts: true })
|
||||
doc.text(cBetrag, y, formatEuro(betrag), { groesse: 10, rechts: true })
|
||||
y += zeilen.length * 12 + 4
|
||||
}
|
||||
doc.linie(L, y - 2, R, y - 2)
|
||||
y += 12
|
||||
|
||||
// Summen (zusammenhaengend auf einer Seite)
|
||||
const s = i.summen || {}
|
||||
const zeilenSumme = []
|
||||
if (klein) {
|
||||
zeilenSumme.push(['Gesamtbetrag', formatEuro(s.bruttoCent ?? 0), true])
|
||||
} else {
|
||||
for (const u of s.ust || []) {
|
||||
zeilenSumme.push([`Nettobetrag ${u.satz} %`, formatEuro(u.nettoCent), false])
|
||||
zeilenSumme.push([`Enthaltene USt ${u.satz} %`, formatEuro(u.steuerCent), false])
|
||||
}
|
||||
zeilenSumme.push(['Gesamtbetrag (brutto)', formatEuro(s.bruttoCent ?? 0), true])
|
||||
}
|
||||
const hinweis = klein ? doc.umbrechen('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', R - L, 9) : []
|
||||
const zahlText = i.zahlart === 'online' ? 'Zahlart: Online-Zahlung' : i.zahlart === 'vorort' ? 'Zahlart: Zahlung vor Ort' : ''
|
||||
const noetig = zeilenSumme.length * 14 + hinweis.length * 12 + 40
|
||||
if (y + noetig > UNTEN) {
|
||||
doc.seite()
|
||||
y = 70
|
||||
}
|
||||
for (const [k, v, fett] of zeilenSumme) {
|
||||
doc.text(cEinzel, y, k, { groesse: 10, fett, rechts: true })
|
||||
doc.text(cBetrag, y, v, { groesse: 10, fett, rechts: true })
|
||||
y += 14
|
||||
}
|
||||
y += 8
|
||||
for (const z of hinweis) {
|
||||
doc.text(L, y, z, { groesse: 9 })
|
||||
y += 12
|
||||
}
|
||||
if (zahlText) {
|
||||
y += 4
|
||||
doc.text(L, y, zahlText, { groesse: 9 })
|
||||
}
|
||||
|
||||
// Fusszeile auf jeder Seite
|
||||
const fuss1 = [nz(a.firma), nz(a.inhaber) && `Inh. ${nz(a.inhaber)}`, nz(a.strasse), ortZeile(a)].filter(Boolean).join(' · ')
|
||||
const fuss2 = [nz(a.steuernummer) && `Steuernummer ${nz(a.steuernummer)}`, nz(a.ustId) && `USt-IdNr. ${nz(a.ustId)}`, nz(a.email), nz(a.telefon)].filter(Boolean).join(' · ')
|
||||
const fuss3 = [nz(a.bank), nz(a.iban) && `IBAN ${nz(a.iban)}`, nz(a.bic) && `BIC ${nz(a.bic)}`].filter(Boolean).join(' · ')
|
||||
const n = doc.seitenzahl
|
||||
for (let p = 0; p < n; p++) {
|
||||
doc.aufSeite(p)
|
||||
doc.linie(L, 790, R, 790, { dicke: 0.3 })
|
||||
;[fuss1, fuss2, fuss3].filter(Boolean).forEach((z, k) => doc.text(L, 802 + k * 10, z, { groesse: 7.5 }))
|
||||
if (n > 1) doc.text(R, 826, `Seite ${p + 1} von ${n}`, { groesse: 7.5, rechts: true })
|
||||
}
|
||||
return doc.toBuffer()
|
||||
}
|
||||
Reference in new issue
Block a user