feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
|
||||
|
||||
/**
|
||||
* Notnagel: den bereits im PDF eingebetteten Text lesen.
|
||||
*
|
||||
* Der Regelweg ist Tesseract. Fehlt auf dem System auch `pdftoppm`, um das
|
||||
* PDF in Bilder zu wandeln, bleibt nur dieser Weg - und er reicht
|
||||
* ueberraschend oft: Bescheide von Behoerden und Rechnungen kommen heute
|
||||
* meist als "digitales" PDF mit echtem Text, nicht als Scan.
|
||||
*
|
||||
* Es ist bewusst ein einfacher Extraktor, kein PDF-Renderer:
|
||||
* unkomprimierte und Flate-komprimierte Inhaltsstroeme werden ausgepackt
|
||||
* und die Text-Operatoren (Tj, TJ, ', ") daraus gelesen. Was er nicht kann,
|
||||
* meldet er als "kein Text" - dann bleibt der ocrStatus eben
|
||||
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
|
||||
*
|
||||
* Rein, ohne Seiteneffekte, ohne Netz.
|
||||
*/
|
||||
|
||||
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
|
||||
function literalEntpacken(roh: string): string {
|
||||
let ergebnis = ''
|
||||
for (let i = 0; i < roh.length; i++) {
|
||||
const zeichen = roh[i]!
|
||||
if (zeichen !== '\\') {
|
||||
ergebnis += zeichen
|
||||
continue
|
||||
}
|
||||
const naechstes = roh[++i]
|
||||
if (naechstes === undefined) break
|
||||
switch (naechstes) {
|
||||
case 'n':
|
||||
ergebnis += '\n'
|
||||
break
|
||||
case 'r':
|
||||
ergebnis += '\n'
|
||||
break
|
||||
case 't':
|
||||
ergebnis += '\t'
|
||||
break
|
||||
case 'b':
|
||||
case 'f':
|
||||
ergebnis += ' '
|
||||
break
|
||||
case '\n':
|
||||
break // Zeilenumbruch im Quelltext, kein Zeichen
|
||||
default:
|
||||
if (naechstes >= '0' && naechstes <= '7') {
|
||||
let oktal = naechstes
|
||||
while (oktal.length < 3 && roh[i + 1] !== undefined && roh[i + 1]! >= '0' && roh[i + 1]! <= '7') {
|
||||
oktal += roh[++i]!
|
||||
}
|
||||
ergebnis += String.fromCharCode(Number.parseInt(oktal, 8))
|
||||
} else {
|
||||
ergebnis += naechstes
|
||||
}
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF-Bytes in Zeichen wandeln. Ohne die Font-Tabelle des Dokuments ist
|
||||
* das eine Naeherung; fuer WinAnsi (der Normalfall bei deutschen Texten)
|
||||
* stimmt sie, inklusive Umlauten.
|
||||
*/
|
||||
const WINANSI_SONDER: Record<number, string> = {
|
||||
0x80: '€',
|
||||
0x82: '‚',
|
||||
0x84: '„',
|
||||
0x85: '…',
|
||||
0x91: '‘',
|
||||
0x92: '’',
|
||||
0x93: '“',
|
||||
0x94: '”',
|
||||
0x95: '•',
|
||||
0x96: '–',
|
||||
0x97: '—',
|
||||
0x9c: 'œ',
|
||||
0x9f: 'Ÿ',
|
||||
}
|
||||
|
||||
function bytesZuText(roh: string): string {
|
||||
let ergebnis = ''
|
||||
for (let i = 0; i < roh.length; i++) {
|
||||
const code = roh.charCodeAt(i)
|
||||
ergebnis += WINANSI_SONDER[code] ?? String.fromCharCode(code)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Hex-String <48616C6C6F> */
|
||||
function hexEntpacken(roh: string): string {
|
||||
const sauber = roh.replace(/[^0-9a-fA-F]/g, '')
|
||||
let ergebnis = ''
|
||||
for (let i = 0; i + 1 < sauber.length; i += 2) {
|
||||
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(i, i + 2), 16))
|
||||
}
|
||||
// Ungerade Laenge: das letzte Nibble wird laut Spezifikation mit 0 aufgefuellt.
|
||||
if (sauber.length % 2 === 1) {
|
||||
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(-1) + '0', 16))
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/** Holt alle Streams aus dem PDF und packt sie aus, soweit moeglich. */
|
||||
function stroemeAuspacken(pdf: Buffer): string[] {
|
||||
const roh = pdf.toString('latin1')
|
||||
const stroeme: string[] = []
|
||||
let suchAb = 0
|
||||
|
||||
while (stroeme.length < 2000) {
|
||||
const beginn = roh.indexOf('stream', suchAb)
|
||||
if (beginn < 0) break
|
||||
|
||||
let inhaltVon = beginn + 'stream'.length
|
||||
if (roh[inhaltVon] === '\r') inhaltVon++
|
||||
if (roh[inhaltVon] === '\n') inhaltVon++
|
||||
|
||||
const ende = roh.indexOf('endstream', inhaltVon)
|
||||
if (ende < 0) break
|
||||
suchAb = ende + 'endstream'.length
|
||||
|
||||
const daten = Buffer.from(roh.slice(inhaltVon, ende), 'latin1')
|
||||
if (daten.length === 0) continue
|
||||
|
||||
// Nur der Kopf des Objekts direkt davor sagt, wie komprimiert wurde.
|
||||
const kopf = roh.slice(Math.max(0, beginn - 500), beginn)
|
||||
if (/\/Filter\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|RunLengthDecode)/.test(kopf)) {
|
||||
continue // Bilddaten - hier ist kein Text zu holen
|
||||
}
|
||||
|
||||
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
|
||||
const entpackt = flateVersuchen(daten)
|
||||
if (entpackt) stroeme.push(entpackt.toString('latin1'))
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/\/Filter/.test(kopf)) {
|
||||
stroeme.push(daten.toString('latin1'))
|
||||
continue
|
||||
}
|
||||
|
||||
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
|
||||
const entpackt = flateVersuchen(daten)
|
||||
if (entpackt) stroeme.push(entpackt.toString('latin1'))
|
||||
}
|
||||
|
||||
return stroeme
|
||||
}
|
||||
|
||||
function flateVersuchen(daten: Buffer): Buffer | null {
|
||||
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
|
||||
try {
|
||||
return fn(daten)
|
||||
} catch {
|
||||
// naechster Versuch
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Liest die Text-Operatoren aus einem ausgepackten Inhaltsstrom. */
|
||||
function textAusStrom(strom: string): string {
|
||||
if (!/\bTj\b|\bTJ\b|\bTd\b|\bTf\b/.test(strom)) return ''
|
||||
|
||||
let ergebnis = ''
|
||||
const muster = /\((?:\\.|[^\\()])*\)|<[0-9a-fA-F\s]*>|\bTJ\b|\bTj\b|\bT\*\b|\bTd\b|\bTD\b|\bET\b|'|"/g
|
||||
|
||||
let treffer: RegExpExecArray | null
|
||||
while ((treffer = muster.exec(strom)) !== null) {
|
||||
const wert = treffer[0]!
|
||||
if (wert.startsWith('(')) {
|
||||
ergebnis += bytesZuText(literalEntpacken(wert.slice(1, -1)))
|
||||
} else if (wert.startsWith('<')) {
|
||||
ergebnis += bytesZuText(hexEntpacken(wert.slice(1, -1)))
|
||||
} else if (wert === 'T*' || wert === 'TD' || wert === 'Td' || wert === 'ET' || wert === "'" || wert === '"') {
|
||||
ergebnis += '\n'
|
||||
} else if (wert === 'TJ' || wert === 'Tj') {
|
||||
ergebnis += ' '
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht den eingebetteten Text aus einem PDF. Gibt eine leere Zeichenkette
|
||||
* zurueck, wenn nichts zu holen ist (reiner Scan, verschluesseltes PDF,
|
||||
* exotische Kodierung) - nie einen Fehler.
|
||||
*/
|
||||
export function pdfTextLesen(pdf: Buffer): string {
|
||||
try {
|
||||
const teile = stroemeAuspacken(pdf).map(textAusStrom).filter((t) => t.trim().length > 0)
|
||||
return textAufraeumen(teile.join('\n'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Vereinheitlicht Leerraum, ohne Zeilenstruktur zu zerstoeren. */
|
||||
export function textAufraeumen(roh: string): string {
|
||||
return roh
|
||||
.replace(/\r\n?/g, '\n')
|
||||
// Nullbytes, weiche Trennstriche und Zero-Width-Zeichen entfernen.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u00ad\u200b-\u200d\ufeff]/g, '')
|
||||
.split('\n')
|
||||
.map((zeile) => zeile.replace(/[ \t\u00a0]+/g, ' ').trim())
|
||||
.filter((zeile, i, alle) => zeile !== '' || (alle[i - 1] ?? '') !== '')
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Grobe Seitenzahl aus der PDF-Struktur. Nutzt zuerst /Count aus dem
|
||||
* Seitenbaum, sonst die Zahl der /Type /Page-Objekte.
|
||||
*/
|
||||
export function pdfSeitenzahlSchaetzen(pdf: Buffer): number | null {
|
||||
const roh = pdf.toString('latin1')
|
||||
const zaehler = [...roh.matchAll(/\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/g)].map((t) =>
|
||||
Number.parseInt(t[1]!, 10),
|
||||
)
|
||||
if (zaehler.length > 0) return Math.max(...zaehler)
|
||||
|
||||
const seiten = roh.match(/\/Type\s*\/Page[^s]/g)
|
||||
return seiten ? seiten.length : null
|
||||
}
|
||||
Reference in New Issue
Block a user