waffensachkunde

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

/ data-pipeline parse_catalog.py

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