/** * 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, }