waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ data-pipeline parse_catalog.py

38,3 KB Rohdatei
data-pipeline/parse_catalog.py — 923 Zeilen
1 # -*- coding: utf-8 -*-
2 """Extrahiert den amtlichen BVA-Fragenkatalog (§ 7 WaffG) in strukturiertes JSON.
3
4 Quelle: "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)",
5 Bundesverwaltungsamt, Stand 16.12.2024. Der Fragenwortlaut wird unverändert
6 übernommen (§ 62 UrhG); es findet keine inhaltliche Bearbeitung statt.
7
8 Layoutgrundlage (an der Vorlage vermessen, siehe explore_*.py):
9 Spalte 1 x 71-103 amtliche Fragennummer
10 Spalte 2 x 104-292 Fragetext
11 Spalte 3 x 292-505 Antwortoptionen bzw. Musterantwort
12 Spalte 4 x 505-524 Ankreuzkästchen (Kreuz = zwei Diagonalsegmente)
13
14 Ein Kästchen markiert jeweils den Beginn einer Antwortoption; Fragen ohne
15 Kästchen sind offene Fragen mit Musterantwort. Kernelemente der Musterantworten
16 sind im PDF unterstrichen (Füllrechtecke der Höhe ~0,84 pt).
17 """
18 from __future__ import annotations
19
20 import argparse
21 import hashlib
22 import io
23 import json
24 import re
25 import sys
26 import unicodedata
27 from dataclasses import dataclass, field
28 from pathlib import Path
29
30 import fitz
31
32 sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
33
34 # --------------------------------------------------------------- Konstanten
35
36 FIRST_CONTENT_PAGE = 5 # 0-basiert; PDF-Seite 6 trägt die erste Frage
37 HEADER_BOTTOM = 92.0 # unterhalb davon beginnt der Fragenbereich
38 COL_NUM_END = 103.0
39 COL_QUESTION_END = 292.0
40 COL_ANSWER_END = 540.0 # Musterantworten laufen bis x≈530
41 CHECKBOX_MIN_X = 500.0 # linkeste Kästchenkante liegt bei x≈503
42
43 CHECKBOX_MIN, CHECKBOX_MAX = 6.0, 20.0
44 CHECKBOX_SQUARENESS = 4.0
45 UNDERLINE_MIN_H, UNDERLINE_MAX_H = 0.6, 1.6 # Tabellenlinien liegen bei ~0,48
46 UNDERLINE_MIN_W = 3.0
47 # Anteil der Wortbreite, den die Unterstreichung überdecken muss.
48 #
49 # War 0,55. Nachgemessen über alle Inhaltsseiten: 285 Wörter liegen deutlich
50 # über der Schwelle, genau ein einziges liegt zwischen 0,05 und 0,55 – die
51 # Verteilung hat dort eine echte Lücke, die Schwelle ist also nicht knapp
52 # gewählt. Dieses eine Wort ist „P2," in Frage IV-36: Der Balken unterstreicht
53 # „Unterklasse P2", überdeckt „P2," aber nur zu 51,7 %. Angezeigt wurde
54 # dadurch nicht die Antwort, sondern ihr halber Anfang.
55 #
56 # 0,40 holt es zurück und ändert im ganzen Katalog sonst nichts – kein
57 # einziger zusätzlicher Treffer, weil zwischen 0,05 und 0,52 nichts liegt.
58 UNDERLINE_MIN_OVERLAP = 0.40
59 LINE_TOLERANCE = 3.0 # y-Toleranz beim Gruppieren zu Textzeilen
60
61 LABEL_INDENT = 20.0 # Optionslabel stehen am linken Rand der Spalte
62
63 OPTION_LABELS = "abcdefgh"
64
65 # Nach einem Bindestrich am Zeilenende folgen diese Wörter bei einem
66 # Ergänzungsstrich ("Waffen- und Munitionsrecht") statt bei Silbentrennung.
67 CONJUNCTIONS = {
68 "und", "oder", "bzw", "bzw.", "sowie", "wie", "als", "noch", "aber",
69 "beziehungsweise", "respektive",
70 }
71
72 CHAPTER_TITLES = {
73 "I": "Waffenrecht und sonstige Rechtsvorschriften",
74 "II": "Waffentechnik (Waffen, Munition, Geschosse)",
75 "III": "Handhabung von Schusswaffen und Munition",
76 "IV": "Not- und Seenotsignalmittel",
77 }
78
79 CATALOG_META = {
80 "titel": "Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)",
81 "herausgeber": "Bundesverwaltungsamt",
82 "stand": "2024-12-16",
83 "quellenangabe": (
84 "Amtlicher Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG) "
85 "des Bundesverwaltungsamtes, Stand 16.12.2024. Diese Software ist kein "
86 "Angebot des Bundesverwaltungsamtes."
87 ),
88 "quelle_url": (
89 "https://www.bva.bund.de/DE/Services/Buerger/Ausweis-Dokumente-Recht/"
90 "Waffenrecht/Sachkundepruefung/sachkunde_node.html"
91 ),
92 }
93
94 # ------------------------------------------------------------ Datenstrukturen
95
96
97 @dataclass
98 class Segment:
99 """Textabschnitt mit Auszeichnung (für hervorgehobene Kernelemente)."""
100
101 text: str
102 hervorgehoben: bool = False
103
104
105 @dataclass
106 class Word:
107 text: str
108 x0: float
109 y0: float
110 x1: float
111 y1: float
112 underlined: bool = False
113
114 @property
115 def cy(self) -> float:
116 return (self.y0 + self.y1) / 2
117
118
119 @dataclass
120 class Option:
121 label: str
122 segmente: list[Segment]
123 korrekt: bool
124 bilder: list[str] = field(default_factory=list)
125 seite: int = 0 # Seite der Option (nur intern)
126 bereich_start: float = 0.0 # y-Bereich der Option auf dieser Seite
127 bereich_ende: float = 0.0
128
129 @property
130 def text(self) -> str:
131 return "".join(s.text for s in self.segmente)
132
133
134 @dataclass
135 class Question:
136 amtliche_nummer: str
137 kapitel: str
138 abschnitt: str | None
139 abschnitt_titel: str | None
140 seite: int
141 frage_segmente: list[Segment] = field(default_factory=list)
142 optionen: list[Option] = field(default_factory=list)
143 antwort_segmente: list[Segment] = field(default_factory=list)
144 bilder: list[str] = field(default_factory=list)
145 warnungen: list[str] = field(default_factory=list)
146
147 @property
148 def typ(self) -> str:
149 if self.optionen:
150 return "mc"
151 if "Lösung:" in "".join(s.text for s in self.antwort_segmente):
152 return "lueckentext"
153 return "freitext"
154
155 @property
156 def id(self) -> str:
157 if self.kapitel == "I":
158 teil, nummer = self.amtliche_nummer.split(".")
159 return f"I.{teil}-{int(nummer):02d}"
160 return f"{self.kapitel}-{int(self.amtliche_nummer):02d}"
161
162
163 # ------------------------------------------------------------- Hilfsfunktionen
164
165
166 def normalise(text: str) -> str:
167 """Vereinheitlicht Sonderzeichen, ohne den Wortlaut zu verändern."""
168 text = text.replace("\u00a0", " ").replace("\u2011", "-")
169 text = unicodedata.normalize("NFC", text)
170 return re.sub(r"[ \t]+", " ", text).strip()
171
172
173 # Ein Trennstrich innerhalb des letzten Worts einer Zeile, die selbst auf
174 # einen Trennstrich endet. Beide gehören zu derselben Silbenkette.
175 INNERER_TRENNSTRICH = re.compile(r"(?<=[a-zäöüß])-(?=[a-zäöüß])")
176
177
178 def _kette_aufloesen(part: str) -> tuple[str, str | None]:
179 """Löst eine mehrfache Silbentrennung im letzten Wort einer Zeile auf.
180
181 Das Original trennt gelegentlich zweimal in einem Wort: »er-wer-« am
182 Zeilenende, »ben« in der nächsten Zeile – gemeint ist »erwerben«. Der
183 Zeilenendstrich verschwindet hier ohnehin; blieb der innere stehen,
184 entstand »er-werben«, also weder das gedruckte Bild noch das gemeinte
185 Wort. Die Zusammenführung wäre auf halbem Weg stehengeblieben.
186
187 Aufgelöst wird ausschließlich zwischen zwei **Kleinbuchstaben** und
188 ausschließlich im letzten Wort einer Zeile, die auf einen Trennstrich
189 endet. Damit bleiben zwei Gruppen unberührt, und das ist Absicht:
190
191 * Komposita mit großem zweiten Glied (»Kleinkaliber-Repetier-« +
192 »gewehr«, »CO2-Waffen«) – dort steht der Strich zu Recht.
193 * Striche mitten in einer Zeile, ohne jeden Umbruch (»er-klärt« in
194 Frage 2.123 b, »orange-farbenen« in IV-52, »lever-action« in 1.28).
195 Sie sind kein Extraktionsschaden, sondern der gedruckte Wortlaut –
196 teils ein Fehler des Herausgebers, teils richtig. Beides zu ändern
197 hieße, am amtlichen Text zu arbeiten; das tut diese Pipeline nicht.
198
199 Liefert das bereinigte Stück und, falls etwas aufgelöst wurde, einen
200 Eintrag für die QS-Liste.
201 """
202 if " " in part:
203 kopf, _, wort = part.rpartition(" ")
204 kopf += " "
205 else:
206 kopf, wort = "", part
207 if not INNERER_TRENNSTRICH.search(wort):
208 return part, None
209 return kopf + INNERER_TRENNSTRICH.sub("", wort), wort
210
211
212 def join_hyphenated(parts: list[str]) -> tuple[str, list[str]]:
213 """Führt am Zeilenende getrennte Wörter zusammen.
214
215 Liefert den Text und die Liste der Fälle, bei denen ein Ergänzungsstrich
216 angenommen wurde (»Waffen- und …«) – diese gehen in den QS-Bericht.
217 """
218 out: list[str] = []
219 ambiguous: list[str] = []
220 for idx, part in enumerate(parts):
221 part = part.rstrip()
222 is_last = idx == len(parts) - 1
223 if not is_last and part.endswith("-") and len(part) > 1:
224 nxt = parts[idx + 1].lstrip()
225 first_word = nxt.split(" ")[0].strip(",;.").lower() if nxt else ""
226 tail = part[:-1].split(" ")[-1]
227 if first_word in CONJUNCTIONS:
228 # Ergänzungsstrich: »Kinder- und Jugendarbeit«
229 ambiguous.append(f"{tail}- {first_word}")
230 out.append(part + " ")
231 elif nxt[:1].isupper():
232 # Echter Bindestrich im Kompositum: »Physikalisch-Technische«
233 ambiguous.append(f"{tail}-{nxt.split(' ')[0]}")
234 out.append(part)
235 elif nxt[:1].isdigit() or tail[:-1].endswith(tuple("0123456789")):
236 # Bindestrich an einer Zahl – kein Silbentrennstrich.
237 #
238 # Zwei gemessene Fälle, beide bis Fassung 0.24.1 verfälscht:
239 # »(DIN/EN 1143-« + »1)« wurde zu »DIN/EN 11431«, einer Norm,
240 # die es nicht gibt – ausgerechnet in einer als richtig
241 # markierten Antwort (I.4-17 b, PDF-Seite 71). Und »ein 13-« +
242 # »jähriger« wurde zu »13jähriger«, während die eigene Antwort
243 # a) derselben Frage »13-jähriger« schreibt (I.2-123,
244 # PDF-Seite 53).
245 #
246 # Deutsch trennt nicht zwischen Ziffer und Folgesilbe: Wo vor
247 # oder nach dem Strich eine Ziffer steht, ist er gedruckter
248 # Wortlaut und kein Extraktionsartefakt.
249 ambiguous.append(f"{tail}-{nxt.split(' ')[0]} (Zahl)")
250 out.append(part)
251 elif nxt[:1] and not nxt[:1].isalnum():
252 # Der Strich schließt eine Einschaltung, die nächste Zeile
253 # beginnt mit einem Satzzeichen: »allgemeine WBK -grün-« +
254 # »(ohne Voreintrag)« (I.2-06 b, PDF-Seite 26). Bis Fassung
255 # 0.24.1 fiel der schließende Strich weg UND das Leerzeichen
256 # dazu – »-grün(ohne Voreintrag)«, ein Wortgebilde, das ein
257 # Bildschirmleser in einem Zug vorliest.
258 ambiguous.append(f"{tail}- {nxt.split(' ')[0]} (Satzzeichen)")
259 out.append(part + " ")
260 else:
261 # Silbentrennung am Zeilenende: »Signalge-« + »bung«
262 stueck, aufgeloest = _kette_aufloesen(part[:-1])
263 if aufgeloest is not None:
264 ambiguous.append(f"{aufgeloest}- {first_word} (doppelt getrennt)")
265 out.append(stueck)
266 else:
267 out.append(part + ("" if is_last else " "))
268 return normalise("".join(out)), ambiguous
269
270
271 """Ab dieser Weite ist der Zwischenraum keine Wortlücke mehr, sondern eine
272 Lücke zum Ausfüllen.
273
274 Gemessen am Original: Ein gewöhnlicher Wortabstand liegt bei rund 3 Punkt,
275 der weiteste innerhalb einer Spalte bei knapp 12. Die Lücken der einen
276 Lückentextfrage (5.01, Seite 73) sind 76 und 119 Punkt weit – sie beginnen
277 also erst weit jenseits jedes Wortabstands.
278 """
279 LUECKE_AB_PUNKT = 24.0
280
281 """Wie eine Lücke im Text dargestellt wird.
282
283 Fünf Unterstriche, weil das Original an dieser Stelle eine Schreiblinie
284 druckt. Kein erfundenes Wort: Was hier steht, gibt das gedruckte Bild wieder
285 und ergänzt den amtlichen Wortlaut nicht.
286
287 Die Sprachausgabe macht daraus „Lücke“ (`renderer/lernen/vorlesetexte.ts`) –
288 eine Reihe von Unterstrichen ist für einen Bildschirmleser sonst entweder
289 stumm oder Buchstabensalat.
290 """
291 LUECKENZEICHEN = "_____"
292
293
294 def words_to_segments(lines: list[list[Word]]) -> tuple[list[Segment], list[str]]:
295 """Baut aus Wortzeilen zusammenhängende Segmente mit Hervorhebungs-Flag."""
296 line_texts: list[str] = []
297 line_marks: list[list[bool]] = []
298 for line in lines:
299 stuecke: list[str] = []
300 marks: list[bool] = []
301 for i, w in enumerate(line):
302 if i > 0:
303 vorher = line[i - 1]
304 if w.x0 - vorher.x1 >= LUECKE_AB_PUNKT:
305 # Eine Lücke gehört zu keinem Wort und trägt deshalb keine
306 # Hervorhebung – sonst stünde sie als „Kernelement“ in der
307 # Prüfliste unter der Musterantwort.
308 #
309 # Folgt ein Satzzeichen, entfällt das Leerzeichen dahinter:
310 # Im Original schließt das Komma unmittelbar an die
311 # Schreiblinie an, und „diejenige _____ ,“ wäre die einzige
312 # Stelle im ganzen Katalog mit einem Leerzeichen vor einem
313 # Komma.
314 nachsatz = "" if w.text[:1] in ",;.:!?" else " "
315 trenner = f" {LUECKENZEICHEN}{nachsatz}"
316 marks.extend([False] * len(trenner))
317 else:
318 # Das Leerzeichen nach einem Wort erbt dessen Markierung –
319 # wie seit jeher; die Begründung steht unten.
320 trenner = " "
321 marks.append(vorher.underlined)
322 stuecke.append(trenner)
323 stuecke.append(w.text)
324 marks.extend([w.underlined] * len(w.text))
325 line_texts.append("".join(stuecke))
326 line_marks.append(marks)
327
328 merged_text, ambiguous = join_hyphenated(line_texts)
329
330 # Hervorhebungen zeichenweise auf den zusammengeführten Text übertragen.
331 #
332 # Das Trennzeichen zwischen zwei Zeilen erbt die Markierung, wenn beide
333 # Seiten markiert sind. Vorher stand hier fest `False`, und das zerriss jede
334 # Unterstreichung, die über einen Zeilenumbruch lief.
335 #
336 # Nachgewiesen an Frage 1.03: Die Zeile endet mit unterstrichenem „zum“, die
337 # nächste beginnt mit unterstrichenem „Angriff“. Die Worterkennung markiert
338 # beide richtig – erst hier wurden sie getrennt. Angezeigt wurde daraufhin
339 # unter „Diese Kernelemente muss Ihre Antwort enthalten“ als erster Punkt
340 # das Wort „zum“.
341 #
342 # Innerhalb einer Zeile geschieht dasselbe längst: Das Leerzeichen nach
343 # einem Wort erbt dessen Markierung (siehe `marks.extend` oben). Diese
344 # Zeile stellt nur die Gleichbehandlung über den Umbruch hinweg her – sie
345 # erfindet kein Zeichen, sondern führt zusammen, was die Quelle
346 # nachweislich zusammen unterstrichen hat.
347 #
348 # Gemessen: 14 zerrissene Listen werden geheilt, 89 Einträge werden zu 71,
349 # danach bleibt kein zerrissener Lauf und kein reiner Funktionswort-Eintrag
350 # übrig.
351 flat: list[bool] = []
352 for i, marks in enumerate(line_marks):
353 if i > 0:
354 # Trennstelle zur vorigen Zeile: markiert, wenn beide Seiten es sind.
355 vorher = flat[-1] if flat else False
356 nachher = marks[0] if marks else False
357 flat.append(vorher and nachher)
358 flat.extend(marks)
359
360 raw = " ".join(line_texts)
361 segments = _align_marks(raw, flat, merged_text)
362 return segments, ambiguous
363
364
365 def _align_marks(raw: str, marks: list[bool], merged: str) -> list[Segment]:
366 """Überträgt zeichenweise Auszeichnungen vom Roh- auf den bereinigten Text."""
367 if len(marks) < len(raw):
368 marks = marks + [False] * (len(raw) - len(marks))
369 result: list[Segment] = []
370 ri = 0
371 for ch in merged:
372 # nächstes passendes Zeichen im Rohtext suchen (Trennstriche entfallen)
373 while ri < len(raw) and raw[ri] != ch:
374 ri += 1
375 flag = marks[ri] if ri < len(marks) else False
376 ri += 1
377 if result and result[-1].hervorgehoben == flag:
378 result[-1].text += ch
379 else:
380 result.append(Segment(ch, flag))
381 # Whitespace am Rand eines hervorgehobenen Segments neutralisieren
382 for seg in result:
383 if seg.hervorgehoben and not seg.text.strip():
384 seg.hervorgehoben = False
385 return _merge_adjacent(result)
386
387
388 def _merge_adjacent(segments: list[Segment]) -> list[Segment]:
389 merged: list[Segment] = []
390 for seg in segments:
391 if merged and merged[-1].hervorgehoben == seg.hervorgehoben:
392 merged[-1].text += seg.text
393 elif seg.text:
394 merged.append(Segment(seg.text, seg.hervorgehoben))
395 return [s for s in merged if s.text]
396
397
398 def group_lines(words: list[Word]) -> list[list[Word]]:
399 """Gruppiert Wörter anhand ihrer Grundlinie zu Textzeilen."""
400 lines: list[list[Word]] = []
401 for w in sorted(words, key=lambda w: (round(w.cy, 1), w.x0)):
402 if lines and abs(lines[-1][0].cy - w.cy) <= LINE_TOLERANCE:
403 lines[-1].append(w)
404 else:
405 lines.append([w])
406 for line in lines:
407 line.sort(key=lambda w: w.x0)
408 return lines
409
410
411 # ------------------------------------------------------------- Seitenanalyse
412
413
414 class PageData:
415 """Aufbereitete Geometrie einer PDF-Seite."""
416
417 def __init__(self, page: fitz.Page, page_no: int):
418 self.page = page
419 self.page_no = page_no
420 self.checkboxes: list[fitz.Rect] = []
421 self.diagonals: list[tuple[fitz.Point, fitz.Point]] = []
422 self.underlines: list[fitz.Rect] = []
423 self.row_separators: list[float] = []
424 self._collect_vectors()
425 self.words = self._collect_words()
426 self.images = self._collect_images()
427
428 def _collect_vectors(self) -> None:
429 for d in self.page.get_drawings():
430 filled = d.get("fill") is not None
431 for item in d["items"]:
432 if item[0] == "re":
433 r = item[1]
434 if (CHECKBOX_MIN < r.width < CHECKBOX_MAX
435 and abs(r.width - r.height) < CHECKBOX_SQUARENESS
436 and r.x0 > CHECKBOX_MIN_X):
437 self.checkboxes.append(r)
438 elif (filled and UNDERLINE_MIN_H < r.height < UNDERLINE_MAX_H
439 and r.width > UNDERLINE_MIN_W):
440 self.underlines.append(r)
441 elif (filled and r.height < UNDERLINE_MIN_H
442 and r.width > 300 and r.y0 > HEADER_BOTTOM):
443 self.row_separators.append(r.y0)
444 elif item[0] == "l":
445 p1, p2 = item[1], item[2]
446 if abs(p1.x - p2.x) > 2 and abs(p1.y - p2.y) > 2:
447 self.diagonals.append((p1, p2))
448 elif (abs(p1.y - p2.y) < 0.7 and abs(p1.x - p2.x) > 300
449 and p1.y > HEADER_BOTTOM):
450 self.row_separators.append(p1.y)
451 self.checkboxes.sort(key=lambda r: r.y0)
452 self.row_separators = sorted(set(round(y, 1) for y in self.row_separators))
453
454 def _collect_words(self) -> list[Word]:
455 out: list[Word] = []
456 for x0, y0, x1, y1, text, *_ in self.page.get_text("words"):
457 if y0 < HEADER_BOTTOM:
458 continue
459 w = Word(normalise(text), x0, y0, x1, y1)
460 if w.text:
461 w.underlined = self._is_underlined(w)
462 out.append(w)
463 return out
464
465 def _is_underlined(self, w: Word) -> bool:
466 """Ein Wort gilt als hervorgehoben, wenn direkt darunter eine
467 Unterstreichung mit deutlicher horizontaler Überlappung liegt."""
468 for u in self.underlines:
469 if not (-1.5 <= u.y0 - w.y1 <= 4.5):
470 continue
471 overlap = min(w.x1, u.x1) - max(w.x0, u.x0)
472 if overlap > UNDERLINE_MIN_OVERLAP * (w.x1 - w.x0):
473 return True
474 return False
475
476 def _collect_images(self) -> list[tuple[fitz.Rect, str]]:
477 out: list[tuple[fitz.Rect, str]] = []
478 for info in self.page.get_images(full=True):
479 xref = info[0]
480 for rect in self.page.get_image_rects(xref):
481 if rect.y0 >= HEADER_BOTTOM:
482 out.append((rect, str(xref)))
483 return out
484
485 def is_checked(self, box: fitz.Rect) -> bool:
486 hits = 0
487 for p1, p2 in self.diagonals:
488 inside = (box.x0 - 2 <= p1.x <= box.x1 + 2 and box.y0 - 2 <= p1.y <= box.y1 + 2
489 and box.x0 - 2 <= p2.x <= box.x1 + 2 and box.y0 - 2 <= p2.y <= box.y1 + 2)
490 if inside:
491 hits += 1
492 return hits >= 2
493
494 def question_anchors(self) -> list[tuple[str, float]]:
495 """Amtliche Fragennummern der Seite mit ihrer y-Position."""
496 pattern = re.compile(r"^(\d{1,3}\.\d{2,3}|\d{1,3})$")
497 anchors = []
498 for w in self.words:
499 if w.x0 < COL_NUM_END and pattern.match(w.text):
500 anchors.append((w.text, w.y0))
501 anchors.sort(key=lambda a: a[1])
502 return anchors
503
504
505 # ----------------------------------------------------------------- Extraktion
506
507
508 class CatalogParser:
509 def __init__(self, pdf_path: Path, alt_path: Path | None = None):
510 self.doc = fitz.open(pdf_path)
511 self.pdf_path = pdf_path
512 self.questions: list[Question] = []
513 self.hyphen_cases: list[tuple[str, str]] = []
514 self.assets: dict[str, dict] = {}
515 # Alternativtexte sind redaktioneller Inhalt und werden getrennt vom
516 # amtlichen Katalog gepflegt (content/alttexte.json).
517 self.alt_texts: dict[str, dict] = {}
518 if alt_path and alt_path.exists():
519 self.alt_texts = {
520 k: v for k, v in json.loads(alt_path.read_text(encoding="utf-8")).items()
521 if not k.startswith("_")
522 }
523
524 # -- Kapitelkontext -----------------------------------------------------
525
526 def _chapter_context(self, page: fitz.Page) -> tuple[str, str | None, str | None]:
527 head = page.get_text("text", clip=fitz.Rect(60, 30, 540, HEADER_BOTTOM))
528 lines = [l.strip() for l in head.splitlines() if l.strip()]
529 chapter = None
530 for line in lines:
531 m = re.match(r"Kapitel\s+(I{1,3}V?|IV)\.", line)
532 if m:
533 chapter = m.group(1)
534 break
535 section = section_title = None
536 for line in lines:
537 m = re.match(r"^(\d)\.\s+(.+)$", line)
538 if m and chapter == "I":
539 section = f"I.{m.group(1)}"
540 section_title = m.group(2).strip()
541 break
542 return chapter or "?", section, section_title
543
544 # -- Hauptlauf ----------------------------------------------------------
545
546 def parse(self) -> None:
547 pending: Question | None = None
548 for pno in range(FIRST_CONTENT_PAGE, self.doc.page_count):
549 page = self.doc[pno]
550 data = PageData(page, pno + 1)
551 chapter, section, section_title = self._chapter_context(page)
552 anchors = data.question_anchors()
553
554 # Bereich oberhalb der ersten Fragennummer gehört zur Vorseiten-Frage.
555 # Die Grenze wird identisch zum regulären Fall gesetzt, damit die
556 # Bereiche lückenlos und überschneidungsfrei bleiben – sonst würde
557 # etwa ein Prüfzeichen, das minimal höher sitzt als die zugehörige
558 # Fragennummer, zusätzlich der Vorseiten-Frage zugeschlagen.
559 if pending is not None:
560 upper = anchors[0][1] - LINE_TOLERANCE if anchors else 10_000.0
561 self._fill(pending, data, HEADER_BOTTOM, upper, continuation=True)
562
563 for idx, (number, y0) in enumerate(anchors):
564 y1 = anchors[idx + 1][1] if idx + 1 < len(anchors) else 10_000.0
565 q = Question(
566 amtliche_nummer=number,
567 kapitel=chapter,
568 abschnitt=section,
569 abschnitt_titel=section_title,
570 seite=pno + 1,
571 )
572 self._fill(q, data, y0 - LINE_TOLERANCE, y1 - LINE_TOLERANCE)
573 self.questions.append(q)
574 pending = q if idx == len(anchors) - 1 else None
575
576 if not anchors and pending is None:
577 continue
578
579 self._melde_leere_optionen()
580
581 def _melde_leere_optionen(self) -> None:
582 """Meldet Antwortmöglichkeiten, die weder Text noch Bild tragen.
583
584 Bis Fassung 0.19.2 stand hier ein Zerschneider: Fand er eine leere
585 Antwortmöglichkeit, deren Bereich ein Bild der Nachbaroption
586 überlappte, zerschnitt er dieses Bild anhand der hellsten Pixelzeile
587 und gab jeder Seite eine Hälfte. Er sprang im ganzen Katalog genau
588 einmal an – bei Frage I.3-05 – und lag dort falsch: Er trennte ein
589 Doppelzeichen, das zusammengehört, und hängte die obere Hälfte
590 (BKA-Raute) an die *falsche* Antwortmöglichkeit. Die Ursache lag eine
591 Stufe früher, in der Zuordnung; siehe {@link _option_for_image}.
592
593 Ein Bild an der falschen Antwort ist in einer Prüfungssoftware kein
594 Schönheitsfehler. Deshalb rät hier nichts mehr: Bleibt eine
595 Antwortmöglichkeit leer, sagt der Katalog das, und ein Mensch sieht
596 nach. Im vorliegenden Katalog bleibt keine leer.
597 """
598 for q in self.questions:
599 for o in q.optionen:
600 if not o.text.strip() and not o.bilder:
601 q.warnungen.append(
602 f"Antwortmöglichkeit {o.label}) trägt weder Text noch Bild")
603
604 def _fill(self, q: Question, data: PageData, top: float, bottom: float,
605 continuation: bool = False) -> None:
606 """Trägt Fragetext, Optionen, Musterantwort und Bilder einer Seite ein."""
607 in_range = [w for w in data.words if top <= w.y0 < bottom]
608 q_words = [w for w in in_range if COL_NUM_END <= w.x0 < COL_QUESTION_END]
609 a_words = [w for w in in_range if COL_QUESTION_END <= w.x0 < COL_ANSWER_END]
610 boxes = [b for b in data.checkboxes if top <= b.y0 < bottom]
611
612 if q_words:
613 segs, amb = words_to_segments(group_lines(q_words))
614 self._append(q.frage_segmente, segs, continuation)
615 self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]
616
617 if boxes:
618 self._fill_options(q, data, a_words, boxes, bottom)
619 elif a_words:
620 segs, amb = words_to_segments(group_lines(a_words))
621 self._append(q.antwort_segmente, segs, continuation or bool(q.antwort_segmente))
622 self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]
623
624 self._assign_images(q, data, top, bottom)
625
626 def _assign_images(self, q: Question, data: PageData, top: float, bottom: float) -> None:
627 """Ordnet Abbildungen der Frage bzw. den Antwortoptionen zu."""
628 for rect, xref in data.images:
629 if not (top <= rect.y0 < bottom):
630 continue
631 if rect.x0 < COL_QUESTION_END or not q.optionen:
632 self._add_image(q.bilder, self._register_asset(xref, data))
633 continue
634 option = self._option_for_image(q, data.page_no, rect)
635 self._add_image(option.bilder if option else q.bilder,
636 self._register_asset(xref, data))
637
638 @staticmethod
639 def _add_image(target: list[str], key: str) -> None:
640 if key not in target:
641 target.append(key)
642
643 @staticmethod
644 def _append(target: list[Segment], segs: list[Segment], joined: bool) -> None:
645 if target and segs:
646 target.append(Segment(" " if joined else "\n"))
647 target.extend(segs)
648
649 @staticmethod
650 def _option_for_image(q: Question, page_no: int, rect: fitz.Rect) -> Option | None:
651 """Ordnet ein Bild der Antwortoption zu.
652
653 Zwei Regeln, in dieser Reihenfolge:
654
655 **1. Das Optionslabel steht im Bild.** Besteht eine Antwortmöglichkeit
656 nur aus einem Prüfzeichen und trägt keinen Text, setzt der Satz das
657 Zeichen senkrecht mittig in seine Tabellenzeile. Das Label sitzt dann
658 *innerhalb* des Bildrechtecks: Das Zeichen beginnt oberhalb seines
659 eigenen Labels und kann bis in die nächste Zeile hineinreichen. Bei
660 einem Zeichen doppelter Höhe liegt seine Mitte dadurch noch vor dem
661 eigenen Label – die Mitte sagt hier nichts mehr.
662
663 **2. Sonst die Bildmitte.** Steht neben dem Zeichen auch Text, hängt es
664 unter der ersten Zeile seiner Option; dann trifft der Bereich zwischen
665 zwei Labels zu.
666
667 Nachgemessen über den ganzen Katalog: 43 Bilder stehen in
668 Antwortspalten. Bei 38 liegt kein Label im Bildrechteck – dort
669 entscheidet weiterhin Regel 2, unverändert. Die übrigen 5 gehören
670 sämtlich zu Frage I.3-05, der einzigen, deren Antwortmöglichkeiten
671 ausschließlich aus Zeichen bestehen. Vier davon ordnen beide Regeln
672 gleich zu; beim fünften – dem Doppelzeichen BKA-Raute über
673 PTB-Trapez – widersprechen sie sich, und Regel 1 hat recht: Dasselbe
674 Bildobjekt bildet in Frage I.2-70 ungeteilt eine einzige
675 Antwortmöglichkeit („Reizstoff-Sprühdosen mit dem Zeichen“).
676 """
677 im_bild = [
678 o for o in q.optionen
679 if o.seite == page_no and rect.y0 <= o.bereich_start + LINE_TOLERANCE < rect.y1
680 ]
681 if len(im_bild) == 1:
682 return im_bild[0]
683
684 mitte = (rect.y0 + rect.y1) / 2
685 for o in q.optionen:
686 if o.seite == page_no and o.bereich_start <= mitte < o.bereich_ende:
687 return o
688 return None
689
690 def _fill_options(self, q: Question, data: PageData, a_words: list[Word],
691 boxes: list[fitz.Rect], bottom: float) -> None:
692 """Zerlegt die Antwortspalte in Optionen.
693
694 Die Grenzen ergeben sich aus den Optionslabels (»a)«, »b)«, …) am linken
695 Spaltenrand, nicht aus den Kästchen: Kästchen sitzen vertikal mittig zur
696 Option und lägen bei mehrzeiligen Optionen unterhalb deren erster Zeile.
697 Jedem Optionsbereich wird anschließend das darin liegende Kästchen
698 zugeordnet.
699 """
700 starts = self._label_positions(a_words)
701 if len(starts) != len(boxes):
702 q.warnungen.append(
703 f"S.{data.page_no}: {len(starts)} Optionslabel, {len(boxes)} Kästchen "
704 f"– Zuordnung über Kästchenposition")
705 starts = [(OPTION_LABELS[i] if i < len(OPTION_LABELS) else str(i + 1), b.y0)
706 for i, b in enumerate(boxes)]
707
708 for idx, (label, y_start) in enumerate(starts):
709 start = y_start - LINE_TOLERANCE
710 end = starts[idx + 1][1] - LINE_TOLERANCE if idx + 1 < len(starts) else bottom
711 chunk = [w for w in a_words if start <= w.y0 < end]
712 segs: list[Segment] = []
713 if chunk:
714 segs, amb = words_to_segments(group_lines(chunk))
715 self.hyphen_cases += [(q.amtliche_nummer, a) for a in amb]
716 # Optionen ohne Text sind zulässig: dort ist ein Prüfzeichen (Bild)
717 # die Antwort. Das Bild wird später über den Bereich zugeordnet.
718 _, segs = self._strip_label(segs, idx)
719 # Optionen und Kästchen stehen beide streng von oben nach unten und
720 # sind gleich viele – die Zuordnung erfolgt daher über die Position
721 # in der Reihenfolge, nicht über y-Bereiche (Kästchen sitzen mittig).
722 q.optionen.append(Option(label=label, segmente=segs,
723 korrekt=data.is_checked(boxes[idx]),
724 seite=data.page_no,
725 bereich_start=start, bereich_ende=end))
726
727 @staticmethod
728 def _label_positions(a_words: list[Word]) -> list[tuple[str, float]]:
729 """Findet Optionslabels am linken Rand der Antwortspalte."""
730 out: list[tuple[str, float]] = []
731 for w in a_words:
732 if w.x0 < COL_QUESTION_END + LABEL_INDENT and re.fullmatch(r"[a-h]\)", w.text):
733 out.append((w.text[0], w.y0))
734 out.sort(key=lambda t: t[1])
735 return out
736
737 @staticmethod
738 def _strip_label(segs: list[Segment], index: int) -> tuple[str, list[Segment]]:
739 """Trennt das führende »a)« vom Optionstext ab."""
740 joined = "".join(s.text for s in segs)
741 m = re.match(r"^([a-h])\)\s*", joined)
742 if not m:
743 return OPTION_LABELS[index] if index < len(OPTION_LABELS) else str(index + 1), segs
744 cut = m.end()
745 out: list[Segment] = []
746 for seg in segs:
747 if cut <= 0:
748 out.append(seg)
749 elif len(seg.text) <= cut:
750 cut -= len(seg.text)
751 else:
752 out.append(Segment(seg.text[cut:], seg.hervorgehoben))
753 cut = 0
754 return m.group(1), _merge_adjacent(out)
755
756 # -- Bilder -------------------------------------------------------------
757
758 def _register_asset(self, xref: str, data: PageData) -> str:
759 if xref in self.assets:
760 return self.assets[xref]["id"]
761 pix = fitz.Pixmap(self.doc, int(xref))
762 if pix.n - pix.alpha >= 4:
763 pix = fitz.Pixmap(fitz.csRGB, pix)
764 asset_id = self._store(pix.tobytes("png"), pix.width, pix.height)
765 self.assets[xref] = next(m for m in self.assets.values() if m["id"] == asset_id)
766 return asset_id
767
768 def _store(self, blob: bytes, breite: int, hoehe: int) -> str:
769 """Legt ein Bild ab; inhaltsgleiche Bilder teilen sich eine Datei."""
770 digest = hashlib.sha1(blob).hexdigest()[:8]
771 for meta in self.assets.values():
772 if meta["sha1"] == digest:
773 return meta["id"]
774 asset_id = f"zeichen-{digest}"
775 self.assets[f"sha:{digest}"] = {"id": asset_id, "sha1": digest, "png": blob,
776 "breite": breite, "hoehe": hoehe}
777 return asset_id
778
779 # -- Ausgabe ------------------------------------------------------------
780
781 def referenced_assets(self) -> set[str]:
782 """Bild-IDs, die tatsächlich an einer Frage oder Option hängen.
783
784 Beim Zerschneiden entstehen Ersatzbilder; das ursprüngliche Kombibild
785 wird dann nicht mehr referenziert und soll auch nicht ausgegeben werden.
786 """
787 used: set[str] = set()
788 for q in self.questions:
789 used.update(q.bilder)
790 for o in q.optionen:
791 used.update(o.bilder)
792 return used
793
794 def to_json(self) -> dict:
795 chapters: dict[str, dict] = {}
796 for q in self.questions:
797 ch = chapters.setdefault(q.kapitel, {
798 "id": q.kapitel, "titel": CHAPTER_TITLES.get(q.kapitel, ""), "abschnitte": {},
799 })
800 if q.abschnitt:
801 ch["abschnitte"].setdefault(q.abschnitt, {
802 "id": q.abschnitt, "titel": q.abschnitt_titel,
803 })
804 for ch in chapters.values():
805 ch["abschnitte"] = sorted(ch["abschnitte"].values(), key=lambda a: a["id"])
806
807 return {
808 "meta": {
809 **CATALOG_META,
810 "quelldatei_sha256": sha256_of(self.pdf_path),
811 "fragen_gesamt": len(self.questions),
812 },
813 "kapitel": [chapters[k] for k in ("I", "II", "III", "IV") if k in chapters],
814 # Zweistufiges Bildkonzept (Prüfplan, Szenario S4): `alt` bleibt
815 # neutral und verrät nie die Lösung; `beschreibung` erklärt die
816 # Bedeutung des Zeichens und wird von der Anwendung erst nach dem
817 # Beantworten gezeigt.
818 "bilder": [
819 {"id": m["id"], "datei": f"{m['id']}.png",
820 "breite": m["breite"], "hoehe": m["hoehe"],
821 "alt": self.alt_texts.get(m["id"], {}).get("alt"),
822 "beschreibung": self.alt_texts.get(m["id"], {}).get("beschreibung")}
823 for m in dict((m["id"], m) for m in self.assets.values()).values()
824 if m["id"] in self.referenced_assets()
825 ],
826 "fragen": [self._question_json(q) for q in self.questions],
827 }
828
829 @staticmethod
830 def _question_json(q: Question) -> dict:
831 out = {
832 "id": q.id,
833 "amtliche_nummer": q.amtliche_nummer,
834 "kapitel": q.kapitel,
835 "abschnitt": q.abschnitt,
836 "typ": q.typ,
837 "seite": q.seite,
838 "frage": segments_json(q.frage_segmente),
839 "bilder": q.bilder,
840 }
841 if q.typ == "mc":
842 out["optionen"] = [
843 {"label": o.label, "inhalt": segments_json(o.segmente),
844 "korrekt": o.korrekt, "bilder": o.bilder}
845 for o in q.optionen
846 ]
847 else:
848 out["musterantwort"] = segments_json(q.antwort_segmente)
849 if q.warnungen:
850 out["warnungen"] = q.warnungen
851 return out
852
853
854 def segments_json(segs: list[Segment]) -> dict:
855 merged = _merge_adjacent(segs)
856 return {
857 "text": "".join(s.text for s in merged),
858 "segmente": [{"t": s.text, "h": True} if s.hervorgehoben else {"t": s.text}
859 for s in merged],
860 }
861
862
863 def sha256_of(path: Path) -> str:
864 h = hashlib.sha256()
865 with path.open("rb") as fh:
866 for chunk in iter(lambda: fh.read(1 << 20), b""):
867 h.update(chunk)
868 return h.hexdigest()
869
870
871 # ----------------------------------------------------------------------- CLI
872
873
874 def main() -> int:
875 ap = argparse.ArgumentParser(description=__doc__)
876 ap.add_argument("--pdf", default=str(Path(__file__).resolve().parent.parent
877 / "Fragenkatalog_sachkunde_mitAntworten.pdf"))
878 ap.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "content" / "katalog"))
879 args = ap.parse_args()
880
881 pdf_path = Path(args.pdf)
882 out_dir = Path(args.out)
883 (out_dir / "assets").mkdir(parents=True, exist_ok=True)
884
885 alt_path = Path(__file__).resolve().parent.parent / "content" / "alttexte.json"
886 parser = CatalogParser(pdf_path, alt_path)
887 parser.parse()
888 payload = parser.to_json()
889
890 (out_dir / "katalog.json").write_text(
891 json.dumps(payload, ensure_ascii=False, indent=2),
892 encoding="utf-8",
893 # LF wie im Archiv verlangt, nicht das CRLF von Windows.
894 newline="\n",
895 )
896
897 referenced = parser.referenced_assets()
898 written = set()
899 for meta in parser.assets.values():
900 if meta["id"] in written or meta["id"] not in referenced:
901 continue
902 (out_dir / "assets" / f"{meta['id']}.png").write_bytes(meta["png"])
903 written.add(meta["id"])
904
905 (out_dir / "trennstriche.txt").write_text(
906 "\n".join(f"{n}\t{c}" for n, c in parser.hyphen_cases),
907 encoding="utf-8",
908 newline="\n",
909 )
910
911 counts = {}
912 for q in parser.questions:
913 counts[q.typ] = counts.get(q.typ, 0) + 1
914 print(f"Fragen gesamt: {len(parser.questions)}")
915 print(f"Typen: {counts}")
916 print(f"Bilder: {len(written)}")
917 print(f"Ergänzungsstrich-Verdachtsfälle: {len(parser.hyphen_cases)}")
918 print(f"Ausgabe: {out_dir / 'katalog.json'}")
919 return 0
920
921
922 if __name__ == "__main__":
923 raise SystemExit(main())