syncova-policies/scripts/lib/pdf-policy.js
Weapie 328f042307 Syncova Policies: Portal für DSGVO-Datenschutzerklärungen
Next.js 15 App Router mit öffentlicher Übersicht und geschütztem
Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO.

- Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden
- Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen
- Massenimport der Merkblätter des Amtes Leezen aus PDF
- Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:23:51 +02:00

214 lines
6.2 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.

/**
* Liest die Datenschutz-Merkblätter des Amtes Leezen (Art. 12ff. DSGVO) aus.
*
* Die Dokumente folgen einer festen Vorlage: ein über alle Merkblätter
* identischer Textbaustein-Teil (Verantwortlicher, Datenschutzbeauftragte,
* Betroffenen-Rechte, Beschwerde-, Widerrufsrecht) und ein variabler Teil
* (Verarbeitungstätigkeit, Zweck, Rechtsgrundlage, Empfänger, Speicherdauer,
* Quelle, Bereitstellungspflicht und deren Folgen).
*/
const fs = require("fs")
const { PDFParse } = require("pdf-parse")
/** Alle in den 117 Merkblättern vorkommenden Überschriften. */
const BOILERPLATE_HEADINGS = [
"Name und Kontaktdaten des Verantwortlichen",
"Kontaktdaten der Datenschutzbeauftragten",
"Betroffenen-Rechte",
"Beschwerderecht bei der Aufsichtsbehörde",
"Widerrufsrecht bei Einwilligung",
]
/** Reine Gliederungsüberschrift ohne eigenen Inhalt. */
const STRUCTURAL_HEADINGS = ["Zwecke und Rechtsgrundlagen der Verarbeitung"]
const VARIABLE_HEADINGS = [
"Bezeichnung der Verarbeitungstätigkeit",
"Ihre Daten werden zu folgendem Zweck erhoben",
"Ihre Daten wurden aufgrund folgender Rechtsgrundlage erhoben",
"Ihre Pflicht zur Bereitstellung der Daten",
"Folgen, wenn Sie die Daten nicht angeben",
"Wir beabsichtigen, Ihre Daten an folgende Empfänger weiterzuleiten",
"Dauer der Speicherung der personenbezogenen Daten",
"Wir haben Ihre Daten aus folgender Quelle erhalten",
]
const HEADINGS = [
...BOILERPLATE_HEADINGS,
...STRUCTURAL_HEADINGS,
...VARIABLE_HEADINGS,
]
/** Kopf-/Fußzeilen, die auf jeder Seite wiederholt werden. */
const FOOTER_START = "Besuchszeiten:"
const FOOTER_END = "Verfahren: XRechnung"
/**
* Zeilenbreite des PDF-Fließtexts (gemessenes Maximum über alle 117 Dokumente).
* Passt das erste Wort der Folgezeile nicht mehr in diese Breite, wurde die
* Zeile vom Layout umbrochen und gehört zur selben logischen Zeile.
*/
const WRAP_WIDTH = 78
async function extractText(filePath) {
const parser = new PDFParse({ data: fs.readFileSync(filePath) })
const result = await parser.getText()
return result.text
}
/** Entfernt Seitenmarker, Fußzeilenblöcke und den Briefkopf. */
function stripChrome(text) {
const lines = text.split(/\r?\n/)
const kept = []
let inFooter = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed === FOOTER_START) {
inFooter = true
continue
}
if (inFooter) {
if (trimmed === FOOTER_END) inFooter = false
continue
}
if (/^--\s*\d+\s+of\s+\d+\s*--$/.test(trimmed)) continue
if (trimmed === "Amt Leezen" || trimmed === "Der Amtsvorsteher") continue
if (trimmed === "Information über die Erhebung von Daten") continue
if (/^\(Art\. 12ff\. EU-Datenschutz-Grundverordnung\)$/.test(trimmed)) continue
kept.push(trimmed)
}
return kept
}
/** Zerlegt das Dokument an den Vorlagen-Überschriften. */
function splitSections(lines) {
const headingSet = new Set(HEADINGS)
const intro = []
const sections = {}
const order = []
let current = null
for (const line of lines) {
if (headingSet.has(line)) {
current = line
if (!sections[current]) {
sections[current] = []
order.push(current)
}
continue
}
if (current) sections[current].push(line)
else if (line) intro.push(line)
}
for (const key of Object.keys(sections)) {
sections[key] = trimBlanks(sections[key])
}
return { intro: trimBlanks(intro), sections, order }
}
function trimBlanks(lines) {
const copy = [...lines]
while (copy.length && !copy[0]) copy.shift()
while (copy.length && !copy[copy.length - 1]) copy.pop()
return copy
}
/**
* Setzt die im PDF hart umbrochenen Zeilen wieder zu logischen Zeilen zusammen.
*
* Entscheidend ist die Frage, ob eine Zeile vom Layout umbrochen wurde oder
* bewusst endete: Hätte das erste Wort der Folgezeile noch in die Zeilenbreite
* gepasst, war der Umbruch gewollt und bleibt erhalten.
*
* Leerzeilen werden ignoriert – sie stammen aus Seitenumbrüchen und trennen im
* Original keine Absätze; ein über einen Seitenumbruch laufender Satz wird so
* korrekt wieder zusammengefügt.
*/
function reflow(lines, wrapWidth = WRAP_WIDTH) {
const result = []
let buffer = ""
for (const raw of lines) {
const line = raw.trim()
if (!line) continue
if (!buffer) {
buffer = line
continue
}
const firstWord = line.split(/\s+/)[0] || ""
// 1. Das erste Wort der Folgezeile hätte nicht mehr in die Zeile gepasst.
const tooLong = buffer.length + 1 + firstWord.length > wrapWidth
// 2. Die Folgezeile beginnt klein – im Deutschen ein Fortsetzungssignal,
// sofern die vorige Zeile nicht ohnehin satzweise endet.
const continuesLowercase =
/^[a-zäöüß]/.test(line) && !/[.:;!?]$/.test(buffer)
const wasWrapped = tooLong || continuesLowercase
if (wasWrapped) {
// Am Zeilenende getrenntes Kompositum ("IT-" + "Infrastruktur") ohne
// Leerzeichen zusammenziehen. Nur wenn der Bindestrich direkt am Wort
// hängt – "StK 30 -" ist ein Trennzeichen, keine Worttrennung.
const hyphenated = /[\wäöüÄÖÜß]-$/.test(buffer) && /^[A-ZÄÖÜ]/.test(line)
buffer += hyphenated ? line : " " + line
} else {
result.push(buffer)
buffer = line
}
}
if (buffer) result.push(buffer)
return result
}
/** Fügt Überschriften-/Titelzeilen zu einer Zeile zusammen. */
function joinTitle(lines) {
return lines
.join(" ")
.replace(/\s+\//g, "/")
.replace(/\s{2,}/g, " ")
.trim()
}
/** Normalisiert Text für den Vergleich der Textbausteine. */
function normalize(lines) {
const value = Array.isArray(lines) ? lines.join(" ") : String(lines)
return value.replace(/\s+/g, " ").trim().toLowerCase()
}
async function parsePolicyPdf(filePath) {
const text = await extractText(filePath)
const lines = stripChrome(text)
const { intro, sections, order } = splitSections(lines)
const unknown = order.filter((heading) => !HEADINGS.includes(heading))
return { text, lines, intro, sections, order, unknown }
}
module.exports = {
HEADINGS,
BOILERPLATE_HEADINGS,
VARIABLE_HEADINGS,
STRUCTURAL_HEADINGS,
WRAP_WIDTH,
parsePolicyPdf,
extractText,
stripChrome,
splitSections,
reflow,
joinTitle,
normalize,
trimBlanks,
}