# -*- coding: utf-8 -*- """Extrahiert den amtlichen BVA-Fragenkatalog (§ 7 WaffG) in strukturiertes JSON. Quelle: "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)", Bundesverwaltungsamt, Stand 16.12.2024. Der Fragenwortlaut wird unverändert übernommen (§ 62 UrhG); es findet keine inhaltliche Bearbeitung statt. Layoutgrundlage (an der Vorlage vermessen, siehe explore_*.py): Spalte 1 x 71-103 amtliche Fragennummer Spalte 2 x 104-292 Fragetext Spalte 3 x 292-505 Antwortoptionen bzw. Musterantwort Spalte 4 x 505-524 Ankreuzkästchen (Kreuz = zwei Diagonalsegmente) Ein Kästchen markiert jeweils den Beginn einer Antwortoption; Fragen ohne Kästchen sind offene Fragen mit Musterantwort. Kernelemente der Musterantworten sind im PDF unterstrichen (Füllrechtecke der Höhe ~0,84 pt). """ from __future__ import annotations import argparse import hashlib import io import json import re import sys import unicodedata from dataclasses import dataclass, field from pathlib import Path import fitz sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") # --------------------------------------------------------------- Konstanten FIRST_CONTENT_PAGE = 5 # 0-basiert; PDF-Seite 6 trägt die erste Frage HEADER_BOTTOM = 92.0 # unterhalb davon beginnt der Fragenbereich COL_NUM_END = 103.0 COL_QUESTION_END = 292.0 COL_ANSWER_END = 540.0 # Musterantworten laufen bis x≈530 CHECKBOX_MIN_X = 500.0 # linkeste Kästchenkante liegt bei x≈503 CHECKBOX_MIN, CHECKBOX_MAX = 6.0, 20.0 CHECKBOX_SQUARENESS = 4.0 UNDERLINE_MIN_H, UNDERLINE_MAX_H = 0.6, 1.6 # Tabellenlinien liegen bei ~0,48 UNDERLINE_MIN_W = 3.0 # Anteil der Wortbreite, den die Unterstreichung überdecken muss. # # War 0,55. Nachgemessen über alle Inhaltsseiten: 285 Wörter liegen deutlich # über der Schwelle, genau ein einziges liegt zwischen 0,05 und 0,55 – die # Verteilung hat dort eine echte Lücke, die Schwelle ist also nicht knapp # gewählt. Dieses eine Wort ist „P2," in Frage IV-36: Der Balken unterstreicht # „Unterklasse P2", überdeckt „P2," aber nur zu 51,7 %. Angezeigt wurde # dadurch nicht die Antwort, sondern ihr halber Anfang. # # 0,40 holt es zurück und ändert im ganzen Katalog sonst nichts – kein # einziger zusätzlicher Treffer, weil zwischen 0,05 und 0,52 nichts liegt. UNDERLINE_MIN_OVERLAP = 0.40 LINE_TOLERANCE = 3.0 # y-Toleranz beim Gruppieren zu Textzeilen LABEL_INDENT = 20.0 # Optionslabel stehen am linken Rand der Spalte OPTION_LABELS = "abcdefgh" # Nach einem Bindestrich am Zeilenende folgen diese Wörter bei einem # Ergänzungsstrich ("Waffen- und Munitionsrecht") statt bei Silbentrennung. CONJUNCTIONS = { "und", "oder", "bzw", "bzw.", "sowie", "wie", "als", "noch", "aber", "beziehungsweise", "respektive", } CHAPTER_TITLES = { "I": "Waffenrecht und sonstige Rechtsvorschriften", "II": "Waffentechnik (Waffen, Munition, Geschosse)", "III": "Handhabung von Schusswaffen und Munition", "IV": "Not- und Seenotsignalmittel", } CATALOG_META = { "titel": "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)", "herausgeber": "Bundesverwaltungsamt", "stand": "2024-12-16", "quellenangabe": ( "Amtlicher Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG) " "des Bundesverwaltungsamtes, Stand 16.12.2024. Diese Software ist kein " "Angebot des Bundesverwaltungsamtes." ), "quelle_url": ( "https://www.bva.bund.de/DE/Services/Buerger/Ausweis-Dokumente-Recht/" "Waffenrecht/Sachkundepruefung/sachkunde_node.html" ), } # ------------------------------------------------------------ Datenstrukturen @dataclass class Segment: """Textabschnitt mit Auszeichnung (für hervorgehobene Kernelemente).""" text: str hervorgehoben: bool = False @dataclass class Word: text: str x0: float y0: float x1: float y1: float underlined: bool = False @property def cy(self) -> float: return (self.y0 + self.y1) / 2 @dataclass class Option: label: str segmente: list[Segment] korrekt: bool bilder: list[str] = field(default_factory=list) seite: int = 0 # Seite der Option (nur intern) bereich_start: float = 0.0 # y-Bereich der Option auf dieser Seite bereich_ende: float = 0.0 @property def text(self) -> str: return "".join(s.text for s in self.segmente) @dataclass class Question: amtliche_nummer: str kapitel: str abschnitt: str | None abschnitt_titel: str | None seite: int frage_segmente: list[Segment] = field(default_factory=list) optionen: list[Option] = field(default_factory=list) antwort_segmente: list[Segment] = field(default_factory=list) bilder: list[str] = field(default_factory=list) warnungen: list[str] = field(default_factory=list) @property def typ(self) -> str: if self.optionen: return "mc" if "Lösung:" in "".join(s.text for s in self.antwort_segmente): return "lueckentext" return "freitext" @property def id(self) -> str: if self.kapitel == "I": teil, nummer = self.amtliche_nummer.split(".") return f"I.{teil}-{int(nummer):02d}" return f"{self.kapitel}-{int(self.amtliche_nummer):02d}" # ------------------------------------------------------------- Hilfsfunktionen def normalise(text: str) -> str: """Vereinheitlicht Sonderzeichen, ohne den Wortlaut zu verändern.""" text = text.replace("\u00a0", " ").replace("\u2011", "-") text = unicodedata.normalize("NFC", text) return re.sub(r"[ \t]+", " ", text).strip() # Ein Trennstrich innerhalb des letzten Worts einer Zeile, die selbst auf # einen Trennstrich endet. Beide gehören zu derselben Silbenkette. INNERER_TRENNSTRICH = re.compile(r"(?<=[a-zäöüß])-(?=[a-zäöüß])") def _kette_aufloesen(part: str) -> tuple[str, str | None]: """Löst eine mehrfache Silbentrennung im letzten Wort einer Zeile auf. Das Original trennt gelegentlich zweimal in einem Wort: »er-wer-« am Zeilenende, »ben« in der nächsten Zeile – gemeint ist »erwerben«. Der Zeilenendstrich verschwindet hier ohnehin; blieb der innere stehen, entstand »er-werben«, also weder das gedruckte Bild noch das gemeinte Wort. Die Zusammenführung wäre auf halbem Weg stehengeblieben. Aufgelöst wird ausschließlich zwischen zwei **Kleinbuchstaben** und ausschließlich im letzten Wort einer Zeile, die auf einen Trennstrich endet. Damit bleiben zwei Gruppen unberührt, und das ist Absicht: * Komposita mit großem zweiten Glied (»Kleinkaliber-Repetier-« + »gewehr«, »CO2-Waffen«) – dort steht der Strich zu Recht. * Striche mitten in einer Zeile, ohne jeden Umbruch (»er-klärt« in Frage 2.123 b, »orange-farbenen« in IV-52, »lever-action« in 1.28). Sie sind kein Extraktionsschaden, sondern der gedruckte Wortlaut – teils ein Fehler des Herausgebers, teils richtig. Beides zu ändern hieße, am amtlichen Text zu arbeiten; das tut diese Pipeline nicht. Liefert das bereinigte Stück und, falls etwas aufgelöst wurde, einen Eintrag für die QS-Liste. """ if " " in part: kopf, _, wort = part.rpartition(" ") kopf += " " else: kopf, wort = "", part if not INNERER_TRENNSTRICH.search(wort): return part, None return kopf + INNERER_TRENNSTRICH.sub("", wort), wort def join_hyphenated(parts: list[str]) -> tuple[str, list[str]]: """Führt am Zeilenende getrennte Wörter zusammen. Liefert den Text und die Liste der Fälle, bei denen ein Ergänzungsstrich angenommen wurde (»Waffen- und …«) – diese gehen in den QS-Bericht. """ out: list[str] = [] ambiguous: list[str] = [] for idx, part in enumerate(parts): part = part.rstrip() is_last = idx == len(parts) - 1 if not is_last and part.endswith("-") and len(part) > 1: nxt = parts[idx + 1].lstrip() first_word = nxt.split(" ")[0].strip(",;.").lower() if nxt else "" tail = part[:-1].split(" ")[-1] if first_word in CONJUNCTIONS: # Ergänzungsstrich: »Kinder- und Jugendarbeit« ambiguous.append(f"{tail}- {first_word}") out.append(part + " ") elif nxt[:1].isupper(): # Echter Bindestrich im Kompositum: »Physikalisch-Technische« ambiguous.append(f"{tail}-{nxt.split(' ')[0]}") out.append(part) elif nxt[:1].isdigit() or tail[:-1].endswith(tuple("0123456789")): # Bindestrich an einer Zahl – kein Silbentrennstrich. # # Zwei gemessene Fälle, beide bis Fassung 0.24.1 verfälscht: # »(DIN/EN 1143-« + »1)« wurde zu »DIN/EN 11431«, einer Norm, # die es nicht gibt – ausgerechnet in einer als richtig # markierten Antwort (I.4-17 b, PDF-Seite 71). Und »ein 13-« + # »jähriger« wurde zu »13jähriger«, während die eigene Antwort # a) derselben Frage »13-jähriger« schreibt (I.2-123, # PDF-Seite 53). # # Deutsch trennt nicht zwischen Ziffer und Folgesilbe: Wo vor # oder nach dem Strich eine Ziffer steht, ist er gedruckter # Wortlaut und kein Extraktionsartefakt. ambiguous.append(f"{tail}-{nxt.split(' ')[0]} (Zahl)") out.append(part) elif nxt[:1] and not nxt[:1].isalnum(): # Der Strich schließt eine Einschaltung, die nächste Zeile # beginnt mit einem Satzzeichen: »allgemeine WBK -grün-« + # »(ohne Voreintrag)« (I.2-06 b, PDF-Seite 26). Bis Fassung # 0.24.1 fiel der schließende Strich weg UND das Leerzeichen # dazu – »-grün(ohne Voreintrag)«, ein Wortgebilde, das ein # Bildschirmleser in einem Zug vorliest. ambiguous.append(f"{tail}- {nxt.split(' ')[0]} (Satzzeichen)") out.append(part + " ") else: # Silbentrennung am Zeilenende: »Signalge-« + »bung« stueck, aufgeloest = _kette_aufloesen(part[:-1]) if aufgeloest is not None: ambiguous.append(f"{aufgeloest}- {first_word} (doppelt getrennt)") out.append(stueck) else: out.append(part + ("" if is_last else " ")) return normalise("".join(out)), ambiguous """Ab dieser Weite ist der Zwischenraum keine Wortlücke mehr, sondern eine Lücke zum Ausfüllen. Gemessen am Original: Ein gewöhnlicher Wortabstand liegt bei rund 3 Punkt, der weiteste innerhalb einer Spalte bei knapp 12. Die Lücken der einen Lückentextfrage (5.01, Seite 73) sind 76 und 119 Punkt weit – sie beginnen also erst weit jenseits jedes Wortabstands. """ LUECKE_AB_PUNKT = 24.0 """Wie eine Lücke im Text dargestellt wird. Fünf Unterstriche, weil das Original an dieser Stelle eine Schreiblinie druckt. Kein erfundenes Wort: Was hier steht, gibt das gedruckte Bild wieder und ergänzt den amtlichen Wortlaut nicht. Die Sprachausgabe macht daraus „Lücke“ (`renderer/lernen/vorlesetexte.ts`) – eine Reihe von Unterstrichen ist für einen Bildschirmleser sonst entweder stumm oder Buchstabensalat. """ LUECKENZEICHEN = "_____" def words_to_segments(lines: list[list[Word]]) -> tuple[list[Segment], list[str]]: """Baut aus Wortzeilen zusammenhängende Segmente mit Hervorhebungs-Flag.""" line_texts: list[str] = [] line_marks: list[list[bool]] = [] for line in lines: stuecke: list[str] = [] marks: list[bool] = [] for i, w in enumerate(line): if i > 0: vorher = line[i - 1] if w.x0 - vorher.x1 >= LUECKE_AB_PUNKT: # Eine Lücke gehört zu keinem Wort und trägt deshalb keine # Hervorhebung – sonst stünde sie als „Kernelement“ in der # Prüfliste unter der Musterantwort. # # Folgt ein Satzzeichen, entfällt das Leerzeichen dahinter: # Im Original schließt das Komma unmittelbar an die # Schreiblinie an, und „diejenige _____ ,“ wäre die einzige # Stelle im ganzen Katalog mit einem Leerzeichen vor einem # Komma. nachsatz = "" if w.text[:1] in ",;.:!?" else " " trenner = f" {LUECKENZEICHEN}{nachsatz}" marks.extend([False] * len(trenner)) else: # Das Leerzeichen nach einem Wort erbt dessen Markierung – # wie seit jeher; die Begründung steht unten. trenner = " " marks.append(vorher.underlined) stuecke.append(trenner) stuecke.append(w.text) marks.extend([w.underlined] * len(w.text)) line_texts.append("".join(stuecke)) line_marks.append(marks) merged_text, ambiguous = join_hyphenated(line_texts) # Hervorhebungen zeichenweise auf den zusammengeführten Text übertragen. # # Das Trennzeichen zwischen zwei Zeilen erbt die Markierung, wenn beide # Seiten markiert sind. Vorher stand hier fest `False`, und das zerriss jede # Unterstreichung, die über einen Zeilenumbruch lief. # # Nachgewiesen an Frage 1.03: Die Zeile endet mit unterstrichenem „zum“, die # nächste beginnt mit unterstrichenem „Angriff“. Die Worterkennung markiert # beide richtig – erst hier wurden sie getrennt. Angezeigt wurde daraufhin # unter „Diese Kernelemente muss Ihre Antwort enthalten“ als erster Punkt # das Wort „zum“. # # Innerhalb einer Zeile geschieht dasselbe längst: Das Leerzeichen nach # einem Wort erbt dessen Markierung (siehe `marks.extend` oben). Diese # Zeile stellt nur die Gleichbehandlung über den Umbruch hinweg her – sie # erfindet kein Zeichen, sondern führt zusammen, was die Quelle # nachweislich zusammen unterstrichen hat. # # Gemessen: 14 zerrissene Listen werden geheilt, 89 Einträge werden zu 71, # danach bleibt kein zerrissener Lauf und kein reiner Funktionswort-Eintrag # übrig. flat: list[bool] = [] for i, marks in enumerate(line_marks): if i > 0: # Trennstelle zur vorigen Zeile: markiert, wenn beide Seiten es sind. vorher = flat[-1] if flat else False nachher = marks[0] if marks else False flat.append(vorher and nachher) flat.extend(marks) raw = " ".join(line_texts) segments = _align_marks(raw, flat, merged_text) return segments, ambiguous def _align_marks(raw: str, marks: list[bool], merged: str) -> list[Segment]: """Überträgt zeichenweise Auszeichnungen vom Roh- auf den bereinigten Text.""" if len(marks) < len(raw): marks = marks + [False] * (len(raw) - len(marks)) result: list[Segment] = [] ri = 0 for ch in merged: # nächstes passendes Zeichen im Rohtext suchen (Trennstriche entfallen) while ri < len(raw) and raw[ri] != ch: ri += 1 flag = marks[ri] if ri < len(marks) else False ri += 1 if result and result[-1].hervorgehoben == flag: result[-1].text += ch else: result.append(Segment(ch, flag)) # Whitespace am Rand eines hervorgehobenen Segments neutralisieren for seg in result: if seg.hervorgehoben and not seg.text.strip(): seg.hervorgehoben = False return _merge_adjacent(result) def _merge_adjacent(segments: list[Segment]) -> list[Segment]: merged: list[Segment] = [] for seg in segments: if merged and merged[-1].hervorgehoben == seg.hervorgehoben: merged[-1].text += seg.text elif seg.text: merged.append(Segment(seg.text, seg.hervorgehoben)) return [s for s in merged if s.text] def group_lines(words: list[Word]) -> list[list[Word]]: """Gruppiert Wörter anhand ihrer Grundlinie zu Textzeilen.""" lines: list[list[Word]] = [] for w in sorted(words, key=lambda w: (round(w.cy, 1), w.x0)): if lines and abs(lines[-1][0].cy - w.cy) <= LINE_TOLERANCE: lines[-1].append(w) else: lines.append([w]) for line in lines: line.sort(key=lambda w: w.x0) return lines # ------------------------------------------------------------- Seitenanalyse class PageData: """Aufbereitete Geometrie einer PDF-Seite.""" def __init__(self, page: fitz.Page, page_no: int): self.page = page self.page_no = page_no self.checkboxes: list[fitz.Rect] = [] self.diagonals: list[tuple[fitz.Point, fitz.Point]] = [] self.underlines: list[fitz.Rect] = [] self.row_separators: list[float] = [] self._collect_vectors() self.words = self._collect_words() self.images = self._collect_images() def _collect_vectors(self) -> None: for d in self.page.get_drawings(): filled = d.get("fill") is not None for item in d["items"]: if item[0] == "re": r = item[1] if (CHECKBOX_MIN < r.width < CHECKBOX_MAX and abs(r.width - r.height) < CHECKBOX_SQUARENESS and r.x0 > CHECKBOX_MIN_X): self.checkboxes.append(r) elif (filled and UNDERLINE_MIN_H < r.height < UNDERLINE_MAX_H and r.width > UNDERLINE_MIN_W): self.underlines.append(r) elif (filled and r.height < UNDERLINE_MIN_H and r.width > 300 and r.y0 > HEADER_BOTTOM): self.row_separators.append(r.y0) elif item[0] == "l": p1, p2 = item[1], item[2] if abs(p1.x - p2.x) > 2 and abs(p1.y - p2.y) > 2: self.diagonals.append((p1, p2)) elif (abs(p1.y - p2.y) < 0.7 and abs(p1.x - p2.x) > 300 and p1.y > HEADER_BOTTOM): self.row_separators.append(p1.y) self.checkboxes.sort(key=lambda r: r.y0) self.row_separators = sorted(set(round(y, 1) for y in self.row_separators)) def _collect_words(self) -> list[Word]: out: list[Word] = [] for x0, y0, x1, y1, text, *_ in self.page.get_text("words"): if y0 < HEADER_BOTTOM: continue w = Word(normalise(text), x0, y0, x1, y1) if w.text: w.underlined = self._is_underlined(w) out.append(w) return out def _is_underlined(self, w: Word) -> bool: """Ein Wort gilt als hervorgehoben, wenn direkt darunter eine Unterstreichung mit deutlicher horizontaler Überlappung liegt.""" for u in self.underlines: if not (-1.5 <= u.y0 - w.y1 <= 4.5): continue overlap = min(w.x1, u.x1) - max(w.x0, u.x0) if overlap > UNDERLINE_MIN_OVERLAP * (w.x1 - w.x0): return True return False def _collect_images(self) -> list[tuple[fitz.Rect, str]]: out: list[tuple[fitz.Rect, str]] = [] for info in self.page.get_images(full=True): xref = info[0] for rect in self.page.get_image_rects(xref): if rect.y0 >= HEADER_BOTTOM: out.append((rect, str(xref))) return out def is_checked(self, box: fitz.Rect) -> bool: hits = 0 for p1, p2 in self.diagonals: inside = (box.x0 - 2 <= p1.x <= box.x1 + 2 and box.y0 - 2 <= p1.y <= box.y1 + 2 and box.x0 - 2 <= p2.x <= box.x1 + 2 and box.y0 - 2 <= p2.y <= box.y1 + 2) if inside: hits += 1 return hits >= 2 def question_anchors(self) -> list[tuple[str, float]]: """Amtliche Fragennummern der Seite mit ihrer y-Position.""" pattern = re.compile(r"^(\d{1,3}\.\d{2,3}|\d{1,3})$") anchors = [] for w in self.words: if w.x0 < COL_NUM_END and pattern.match(w.text): anchors.append((w.text, w.y0)) anchors.sort(key=lambda a: a[1]) return anchors # ----------------------------------------------------------------- Extraktion class CatalogParser: def __init__(self, pdf_path: Path, alt_path: Path | None = None): self.doc = fitz.open(pdf_path) self.pdf_path = pdf_path self.questions: list[Question] = [] self.hyphen_cases: list[tuple[str, str]] = [] self.assets: dict[str, dict] = {} # Alternativtexte sind redaktioneller Inhalt und werden getrennt vom # amtlichen Katalog gepflegt (content/alttexte.json). self.alt_texts: dict[str, dict] = {} if alt_path and alt_path.exists(): self.alt_texts = { k: v for k, v in json.loads(alt_path.read_text(encoding="utf-8")).items() if not k.startswith("_") } # -- Kapitelkontext ----------------------------------------------------- def _chapter_context(self, page: fitz.Page) -> tuple[str, str | None, str | None]: head = page.get_text("text", clip=fitz.Rect(60, 30, 540, HEADER_BOTTOM)) lines = [l.strip() for l in head.splitlines() if l.strip()] chapter = None for line in lines: m = re.match(r"Kapitel\s+(I{1,3}V?|IV)\.", line) if m: chapter = m.group(1) break section = section_title = None for line in lines: m = re.match(r"^(\d)\.\s+(.+)$", line) if m and chapter == "I": section = f"I.{m.group(1)}" section_title = m.group(2).strip() break return chapter or "?", section, section_title # -- Hauptlauf ---------------------------------------------------------- def parse(self) -> None: pending: Question | None = None for pno in range(FIRST_CONTENT_PAGE, self.doc.page_count): page = self.doc[pno] data = PageData(page, pno + 1) chapter, section, section_title = self._chapter_context(page) anchors = data.question_anchors() # Bereich oberhalb der ersten Fragennummer gehört zur Vorseiten-Frage. # Die Grenze wird identisch zum regulären Fall gesetzt, damit die # Bereiche lückenlos und überschneidungsfrei bleiben – sonst würde # etwa ein Prüfzeichen, das minimal höher sitzt als die zugehörige # Fragennummer, zusätzlich der Vorseiten-Frage zugeschlagen. if pending is not None: upper = anchors[0][1] - LINE_TOLERANCE if anchors else 10_000.0 self._fill(pending, data, HEADER_BOTTOM, upper, continuation=True) for idx, (number, y0) in enumerate(anchors): y1 = anchors[idx + 1][1] if idx + 1 < len(anchors) else 10_000.0 q = Question( amtliche_nummer=number, kapitel=chapter, abschnitt=section, abschnitt_titel=section_title, seite=pno + 1, ) self._fill(q, data, y0 - LINE_TOLERANCE, y1 - LINE_TOLERANCE) self.questions.append(q) pending = q if idx == len(anchors) - 1 else None if not anchors and pending is None: continue self._melde_leere_optionen() def _melde_leere_optionen(self) -> None: """Meldet Antwortmöglichkeiten, die weder Text noch Bild tragen. Bis Fassung 0.19.2 stand hier ein Zerschneider: Fand er eine leere Antwortmöglichkeit, deren Bereich ein Bild der Nachbaroption überlappte, zerschnitt er dieses Bild anhand der hellsten Pixelzeile und gab jeder Seite eine Hälfte. Er sprang im ganzen Katalog genau einmal an – bei Frage I.3-05 – und lag dort falsch: Er trennte ein Doppelzeichen, das zusammengehört, und hängte die obere Hälfte (BKA-Raute) an die *falsche* Antwortmöglichkeit. Die Ursache lag eine Stufe früher, in der Zuordnung; siehe {@link _option_for_image}. Ein Bild an der falschen Antwort ist in einer Prüfungssoftware kein Schönheitsfehler. Deshalb rät hier nichts mehr: Bleibt eine Antwortmöglichkeit leer, sagt der Katalog das, und ein Mensch sieht nach. Im vorliegenden Katalog bleibt keine leer. """ for q in self.questions: for o in q.optionen: if not o.text.strip() and not o.bilder: q.warnungen.append( f"Antwortmöglichkeit {o.label}) trägt weder Text noch Bild") def _fill(self, q: Question, data: PageData, top: float, bottom: float, continuation: bool = False) -> None: """Trägt Fragetext, Optionen, Musterantwort und Bilder einer Seite ein.""" in_range = [w for w in data.words if top <= w.y0 < bottom] q_words = [w for w in in_range if COL_NUM_END <= w.x0 < COL_QUESTION_END] a_words = [w for w in in_range if COL_QUESTION_END <= w.x0 < COL_ANSWER_END] boxes = [b for b in data.checkboxes if top <= b.y0 < bottom] if q_words: segs, amb = words_to_segments(group_lines(q_words)) self._append(q.frage_segmente, segs, continuation) self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb] if boxes: self._fill_options(q, data, a_words, boxes, bottom) elif a_words: segs, amb = words_to_segments(group_lines(a_words)) self._append(q.antwort_segmente, segs, continuation or bool(q.antwort_segmente)) self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb] self._assign_images(q, data, top, bottom) def _assign_images(self, q: Question, data: PageData, top: float, bottom: float) -> None: """Ordnet Abbildungen der Frage bzw. den Antwortoptionen zu.""" for rect, xref in data.images: if not (top <= rect.y0 < bottom): continue if rect.x0 < COL_QUESTION_END or not q.optionen: self._add_image(q.bilder, self._register_asset(xref, data)) continue option = self._option_for_image(q, data.page_no, rect) self._add_image(option.bilder if option else q.bilder, self._register_asset(xref, data)) @staticmethod def _add_image(target: list[str], key: str) -> None: if key not in target: target.append(key) @staticmethod def _append(target: list[Segment], segs: list[Segment], joined: bool) -> None: if target and segs: target.append(Segment(" " if joined else "\n")) target.extend(segs) @staticmethod def _option_for_image(q: Question, page_no: int, rect: fitz.Rect) -> Option | None: """Ordnet ein Bild der Antwortoption zu. Zwei Regeln, in dieser Reihenfolge: **1. Das Optionslabel steht im Bild.** Besteht eine Antwortmöglichkeit nur aus einem Prüfzeichen und trägt keinen Text, setzt der Satz das Zeichen senkrecht mittig in seine Tabellenzeile. Das Label sitzt dann *innerhalb* des Bildrechtecks: Das Zeichen beginnt oberhalb seines eigenen Labels und kann bis in die nächste Zeile hineinreichen. Bei einem Zeichen doppelter Höhe liegt seine Mitte dadurch noch vor dem eigenen Label – die Mitte sagt hier nichts mehr. **2. Sonst die Bildmitte.** Steht neben dem Zeichen auch Text, hängt es unter der ersten Zeile seiner Option; dann trifft der Bereich zwischen zwei Labels zu. Nachgemessen über den ganzen Katalog: 43 Bilder stehen in Antwortspalten. Bei 38 liegt kein Label im Bildrechteck – dort entscheidet weiterhin Regel 2, unverändert. Die übrigen 5 gehören sämtlich zu Frage I.3-05, der einzigen, deren Antwortmöglichkeiten ausschließlich aus Zeichen bestehen. Vier davon ordnen beide Regeln gleich zu; beim fünften – dem Doppelzeichen BKA-Raute über PTB-Trapez – widersprechen sie sich, und Regel 1 hat recht: Dasselbe Bildobjekt bildet in Frage I.2-70 ungeteilt eine einzige Antwortmöglichkeit („Reizstoff-Sprühdosen mit dem Zeichen“). """ im_bild = [ o for o in q.optionen if o.seite == page_no and rect.y0 <= o.bereich_start + LINE_TOLERANCE < rect.y1 ] if len(im_bild) == 1: return im_bild[0] mitte = (rect.y0 + rect.y1) / 2 for o in q.optionen: if o.seite == page_no and o.bereich_start <= mitte < o.bereich_ende: return o return None def _fill_options(self, q: Question, data: PageData, a_words: list[Word], boxes: list[fitz.Rect], bottom: float) -> None: """Zerlegt die Antwortspalte in Optionen. Die Grenzen ergeben sich aus den Optionslabels (»a)«, »b)«, …) am linken Spaltenrand, nicht aus den Kästchen: Kästchen sitzen vertikal mittig zur Option und lägen bei mehrzeiligen Optionen unterhalb deren erster Zeile. Jedem Optionsbereich wird anschließend das darin liegende Kästchen zugeordnet. """ starts = self._label_positions(a_words) if len(starts) != len(boxes): q.warnungen.append( f"S.{data.page_no}: {len(starts)} Optionslabel, {len(boxes)} Kästchen " f"– Zuordnung über Kästchenposition") starts = [(OPTION_LABELS[i] if i < len(OPTION_LABELS) else str(i + 1), b.y0) for i, b in enumerate(boxes)] for idx, (label, y_start) in enumerate(starts): start = y_start - LINE_TOLERANCE end = starts[idx + 1][1] - LINE_TOLERANCE if idx + 1 < len(starts) else bottom chunk = [w for w in a_words if start <= w.y0 < end] segs: list[Segment] = [] if chunk: segs, amb = words_to_segments(group_lines(chunk)) self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb] # Optionen ohne Text sind zulässig: dort ist ein Prüfzeichen (Bild) # die Antwort. Das Bild wird später über den Bereich zugeordnet. _, segs = self._strip_label(segs, idx) # Optionen und Kästchen stehen beide streng von oben nach unten und # sind gleich viele – die Zuordnung erfolgt daher über die Position # in der Reihenfolge, nicht über y-Bereiche (Kästchen sitzen mittig). q.optionen.append(Option(label=label, segmente=segs, korrekt=data.is_checked(boxes[idx]), seite=data.page_no, bereich_start=start, bereich_ende=end)) @staticmethod def _label_positions(a_words: list[Word]) -> list[tuple[str, float]]: """Findet Optionslabels am linken Rand der Antwortspalte.""" out: list[tuple[str, float]] = [] for w in a_words: if w.x0 < COL_QUESTION_END + LABEL_INDENT and re.fullmatch(r"[a-h]\)", w.text): out.append((w.text[0], w.y0)) out.sort(key=lambda t: t[1]) return out @staticmethod def _strip_label(segs: list[Segment], index: int) -> tuple[str, list[Segment]]: """Trennt das führende »a)« vom Optionstext ab.""" joined = "".join(s.text for s in segs) m = re.match(r"^([a-h])\)\s*", joined) if not m: return OPTION_LABELS[index] if index < len(OPTION_LABELS) else str(index + 1), segs cut = m.end() out: list[Segment] = [] for seg in segs: if cut <= 0: out.append(seg) elif len(seg.text) <= cut: cut -= len(seg.text) else: out.append(Segment(seg.text[cut:], seg.hervorgehoben)) cut = 0 return m.group(1), _merge_adjacent(out) # -- Bilder ------------------------------------------------------------- def _register_asset(self, xref: str, data: PageData) -> str: if xref in self.assets: return self.assets[xref]["id"] pix = fitz.Pixmap(self.doc, int(xref)) if pix.n - pix.alpha >= 4: pix = fitz.Pixmap(fitz.csRGB, pix) asset_id = self._store(pix.tobytes("png"), pix.width, pix.height) self.assets[xref] = next(m for m in self.assets.values() if m["id"] == asset_id) return asset_id def _store(self, blob: bytes, breite: int, hoehe: int) -> str: """Legt ein Bild ab; inhaltsgleiche Bilder teilen sich eine Datei.""" digest = hashlib.sha1(blob).hexdigest()[:8] for meta in self.assets.values(): if meta["sha1"] == digest: return meta["id"] asset_id = f"zeichen-{digest}" self.assets[f"sha:{digest}"] = {"id": asset_id, "sha1": digest, "png": blob, "breite": breite, "hoehe": hoehe} return asset_id # -- Ausgabe ------------------------------------------------------------ def referenced_assets(self) -> set[str]: """Bild-IDs, die tatsächlich an einer Frage oder Option hängen. Beim Zerschneiden entstehen Ersatzbilder; das ursprüngliche Kombibild wird dann nicht mehr referenziert und soll auch nicht ausgegeben werden. """ used: set[str] = set() for q in self.questions: used.update(q.bilder) for o in q.optionen: used.update(o.bilder) return used def to_json(self) -> dict: chapters: dict[str, dict] = {} for q in self.questions: ch = chapters.setdefault(q.kapitel, { "id": q.kapitel, "titel": CHAPTER_TITLES.get(q.kapitel, ""), "abschnitte": {}, }) if q.abschnitt: ch["abschnitte"].setdefault(q.abschnitt, { "id": q.abschnitt, "titel": q.abschnitt_titel, }) for ch in chapters.values(): ch["abschnitte"] = sorted(ch["abschnitte"].values(), key=lambda a: a["id"]) return { "meta": { **CATALOG_META, "quelldatei_sha256": sha256_of(self.pdf_path), "fragen_gesamt": len(self.questions), }, "kapitel": [chapters[k] for k in ("I", "II", "III", "IV") if k in chapters], # Zweistufiges Bildkonzept (Prüfplan, Szenario S4): `alt` bleibt # neutral und verrät nie die Lösung; `beschreibung` erklärt die # Bedeutung des Zeichens und wird von der Anwendung erst nach dem # Beantworten gezeigt. "bilder": [ {"id": m["id"], "datei": f"{m['id']}.png", "breite": m["breite"], "hoehe": m["hoehe"], "alt": self.alt_texts.get(m["id"], {}).get("alt"), "beschreibung": self.alt_texts.get(m["id"], {}).get("beschreibung")} for m in dict((m["id"], m) for m in self.assets.values()).values() if m["id"] in self.referenced_assets() ], "fragen": [self._question_json(q) for q in self.questions], } @staticmethod def _question_json(q: Question) -> dict: out = { "id": q.id, "amtliche_nummer": q.amtliche_nummer, "kapitel": q.kapitel, "abschnitt": q.abschnitt, "typ": q.typ, "seite": q.seite, "frage": segments_json(q.frage_segmente), "bilder": q.bilder, } if q.typ == "mc": out["optionen"] = [ {"label": o.label, "inhalt": segments_json(o.segmente), "korrekt": o.korrekt, "bilder": o.bilder} for o in q.optionen ] else: out["musterantwort"] = segments_json(q.antwort_segmente) if q.warnungen: out["warnungen"] = q.warnungen return out def segments_json(segs: list[Segment]) -> dict: merged = _merge_adjacent(segs) return { "text": "".join(s.text for s in merged), "segmente": [{"t": s.text, "h": True} if s.hervorgehoben else {"t": s.text} for s in merged], } def sha256_of(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1 << 20), b""): h.update(chunk) return h.hexdigest() # ----------------------------------------------------------------------- CLI def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--pdf", default=str(Path(__file__).resolve().parent.parent / "Fragenkatalog_sachkunde_mitAntworten.pdf")) ap.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "content" / "katalog")) args = ap.parse_args() pdf_path = Path(args.pdf) out_dir = Path(args.out) (out_dir / "assets").mkdir(parents=True, exist_ok=True) alt_path = Path(__file__).resolve().parent.parent / "content" / "alttexte.json" parser = CatalogParser(pdf_path, alt_path) parser.parse() payload = parser.to_json() (out_dir / "katalog.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", # LF wie im Archiv verlangt, nicht das CRLF von Windows. newline="\n", ) referenced = parser.referenced_assets() written = set() for meta in parser.assets.values(): if meta["id"] in written or meta["id"] not in referenced: continue (out_dir / "assets" / f"{meta['id']}.png").write_bytes(meta["png"]) written.add(meta["id"]) (out_dir / "trennstriche.txt").write_text( "\n".join(f"{n}\t{c}" for n, c in parser.hyphen_cases), encoding="utf-8", newline="\n", ) counts = {} for q in parser.questions: counts[q.typ] = counts.get(q.typ, 0) + 1 print(f"Fragen gesamt: {len(parser.questions)}") print(f"Typen: {counts}") print(f"Bilder: {len(written)}") print(f"Ergänzungsstrich-Verdachtsfälle: {len(parser.hyphen_cases)}") print(f"Ausgabe: {out_dir / 'katalog.json'}") return 0 if __name__ == "__main__": raise SystemExit(main())