waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline parse_catalog.py
| 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()) |