waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline build_review.py
| 1 | # -*- coding: utf-8 -*- |
| 2 | """Erzeugt einen HTML-Prüfbericht für die manuelle Sichtprüfung des Katalogs. |
| 3 | |
| 4 | Stellt alle 575 Fragen mit Lösungen, Musterantworten, hervorgehobenen |
| 5 | Kernelementen und Abbildungen dar, damit sie gegen das Original-PDF geprüft |
| 6 | werden können. Der Bericht ist selbst tastaturbedienbar und kontraststark. |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import base64 |
| 11 | import html |
| 12 | import io |
| 13 | import json |
| 14 | import sys |
| 15 | from pathlib import Path |
| 16 | |
| 17 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") |
| 18 | |
| 19 | BASE = Path(__file__).resolve().parent.parent |
| 20 | KATALOG = BASE / "content" / "katalog" |
| 21 | ZIEL = BASE / "docs" / "katalog-pruefbericht.html" |
| 22 | |
| 23 | |
| 24 | def bild_tag(bid: str, bilder: dict) -> str: |
| 25 | meta = bilder.get(bid) |
| 26 | if not meta: |
| 27 | return f'<span class="fehlt">Bild {html.escape(bid)} fehlt</span>' |
| 28 | daten = base64.b64encode((KATALOG / "assets" / meta["datei"]).read_bytes()).decode() |
| 29 | alt = meta.get("alt") or "" |
| 30 | return (f'<img src="data:image/png;base64,{daten}" alt="{html.escape(alt)}" ' |
| 31 | f'title="{html.escape(bid)}">') |
| 32 | |
| 33 | |
| 34 | def segmente_html(feld: dict) -> str: |
| 35 | teile = [] |
| 36 | for s in feld["segmente"]: |
| 37 | text = html.escape(s["t"]).replace("\n", "<br>") |
| 38 | teile.append(f"<mark>{text}</mark>" if s.get("h") else text) |
| 39 | return "".join(teile) or '<span class="fehlt">(kein Text)</span>' |
| 40 | |
| 41 | |
| 42 | def main() -> int: |
| 43 | data = json.loads((KATALOG / "katalog.json").read_text(encoding="utf-8")) |
| 44 | bilder = {b["id"]: b for b in data["bilder"]} |
| 45 | meta = data["meta"] |
| 46 | |
| 47 | abschnitt_titel = {} |
| 48 | for kap in data["kapitel"]: |
| 49 | for a in kap["abschnitte"]: |
| 50 | abschnitt_titel[a["id"]] = a["titel"] |
| 51 | abschnitt_titel[kap["id"]] = kap["titel"] |
| 52 | |
| 53 | zeilen: list[str] = [] |
| 54 | aktuelle_gruppe = None |
| 55 | for f in data["fragen"]: |
| 56 | gruppe = f["abschnitt"] or f["kapitel"] |
| 57 | if gruppe != aktuelle_gruppe: |
| 58 | aktuelle_gruppe = gruppe |
| 59 | titel = abschnitt_titel.get(gruppe, gruppe) |
| 60 | zeilen.append(f'<h2 id="g-{html.escape(gruppe)}">{html.escape(gruppe)} · ' |
| 61 | f'{html.escape(titel)}</h2>') |
| 62 | |
| 63 | typ_label = {"mc": "Multiple Choice", "freitext": "offene Frage", |
| 64 | "lueckentext": "Lückentext"}[f["typ"]] |
| 65 | kopf = (f'<div class="kopf"><span class="id">{html.escape(f["id"])}</span>' |
| 66 | f'<span class="amtlich">amtlich {html.escape(f["amtliche_nummer"])}</span>' |
| 67 | f'<span class="typ typ-{f["typ"]}">{typ_label}</span>' |
| 68 | f'<span class="seite">PDF-Seite {f["seite"]}</span></div>') |
| 69 | |
| 70 | koerper = [f'<p class="frage">{segmente_html(f["frage"])}</p>'] |
| 71 | for bid in f["bilder"]: |
| 72 | koerper.append(f'<p class="bilder">{bild_tag(bid, bilder)}</p>') |
| 73 | |
| 74 | if f["typ"] == "mc": |
| 75 | richtige = sum(1 for o in f["optionen"] if o["korrekt"]) |
| 76 | if richtige > 1: |
| 77 | koerper.append(f'<p class="mehrfach">{richtige} richtige Antworten</p>') |
| 78 | koerper.append("<ul class='optionen'>") |
| 79 | for o in f["optionen"]: |
| 80 | marke = "richtig" if o["korrekt"] else "falsch" |
| 81 | zeichen = "✓" if o["korrekt"] else "✗" |
| 82 | inhalt = segmente_html(o["inhalt"]) if o["inhalt"]["text"].strip() else "" |
| 83 | bild = "".join(bild_tag(b, bilder) for b in o["bilder"]) |
| 84 | koerper.append( |
| 85 | f'<li class="{marke}"><span class="marke" aria-label="' |
| 86 | f'{"richtig" if o["korrekt"] else "falsch"}">{zeichen}</span>' |
| 87 | f'<span class="label">{html.escape(o["label"])})</span>' |
| 88 | f'<span class="text">{inhalt}{bild}</span></li>') |
| 89 | koerper.append("</ul>") |
| 90 | else: |
| 91 | koerper.append(f'<div class="antwort"><span class="ml">Musterantwort</span>' |
| 92 | f'{segmente_html(f["musterantwort"])}</div>') |
| 93 | |
| 94 | if f.get("warnungen"): |
| 95 | hinweise = "; ".join(html.escape(w) for w in f["warnungen"]) |
| 96 | koerper.append(f'<p class="warnung">Hinweis aus der Extraktion: {hinweise}</p>') |
| 97 | |
| 98 | zeilen.append(f'<article class="frage-block" id="{html.escape(f["id"])}">' |
| 99 | f'{kopf}{"".join(koerper)}</article>') |
| 100 | |
| 101 | kennzahlen = [ |
| 102 | ("Fragen", len(data["fragen"])), |
| 103 | ("Multiple Choice", sum(1 for f in data["fragen"] if f["typ"] == "mc")), |
| 104 | ("offene Fragen", sum(1 for f in data["fragen"] if f["typ"] != "mc")), |
| 105 | ("Antwortoptionen", sum(len(f.get("optionen", [])) for f in data["fragen"])), |
| 106 | ("markierte Lösungen", sum(sum(1 for o in f.get("optionen", []) if o["korrekt"]) |
| 107 | for f in data["fragen"])), |
| 108 | ("Abbildungen", len(data["bilder"])), |
| 109 | ] |
| 110 | kacheln = "".join(f'<div class="kachel"><span class="wert">{w}</span>' |
| 111 | f'<span class="bez">{html.escape(b)}</span></div>' |
| 112 | for b, w in kennzahlen) |
| 113 | |
| 114 | nav = "".join( |
| 115 | f'<li><a href="#g-{html.escape(g)}">{html.escape(g)} · {html.escape(t)}</a></li>' |
| 116 | for g, t in abschnitt_titel.items() |
| 117 | if any((f["abschnitt"] or f["kapitel"]) == g for f in data["fragen"])) |
| 118 | |
| 119 | ZIEL.parent.mkdir(parents=True, exist_ok=True) |
| 120 | ZIEL.write_text(SEITE.format( |
| 121 | titel="Prüfbericht Fragenkatalog", |
| 122 | quelle=html.escape(meta["quellenangabe"]), |
| 123 | stand=html.escape(meta["stand"]), |
| 124 | kacheln=kacheln, nav=nav, inhalt="\n".join(zeilen), |
| 125 | # LF wie im Archiv verlangt, nicht das CRLF von Windows. |
| 126 | ), encoding="utf-8", newline="\n") |
| 127 | print(f"Prüfbericht geschrieben: {ZIEL}") |
| 128 | print(f"Größe: {ZIEL.stat().st_size / 1024:.0f} kB") |
| 129 | return 0 |
| 130 | |
| 131 | |
| 132 | SEITE = """<!doctype html> |
| 133 | <html lang="de"> |
| 134 | <head> |
| 135 | <meta charset="utf-8"> |
| 136 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 137 | <title>{titel}</title> |
| 138 | <style> |
| 139 | :root {{ |
| 140 | --bg: #fbfcfb; --flaeche: #f1f5f2; --rand: #d9e0db; |
| 141 | --text: #161c18; --gedaempft: #4f5a53; |
| 142 | --akzent: #2c5c4c; --richtig: #1c6b4a; --falsch: #8a3324; |
| 143 | --markierung: #f7edc9; |
| 144 | }} |
| 145 | @media (prefers-color-scheme: dark) {{ |
| 146 | :root:not([data-theme="light"]) {{ |
| 147 | --bg: #12160f; --flaeche: #1b211c; --rand: #2d352f; |
| 148 | --text: #e8eee9; --gedaempft: #9daaa2; |
| 149 | --akzent: #8ac7ab; --richtig: #7fc9a3; --falsch: #e2957f; |
| 150 | --markierung: #3a3520; |
| 151 | }} |
| 152 | }} |
| 153 | * {{ box-sizing: border-box; }} |
| 154 | body {{ |
| 155 | margin: 0; background: var(--bg); color: var(--text); |
| 156 | font: 16px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif; |
| 157 | }} |
| 158 | a {{ color: var(--akzent); }} |
| 159 | :focus-visible {{ outline: 3px solid var(--akzent); outline-offset: 2px; }} |
| 160 | .huelle {{ max-width: 62rem; margin: 0 auto; padding: 0 1.25rem 5rem; }} |
| 161 | header {{ padding: 2.5rem 0 1.25rem; border-bottom: 2px solid var(--text); }} |
| 162 | h1 {{ font-size: 1.9rem; margin: 0 0 .4rem; letter-spacing: -.01em; }} |
| 163 | .quelle {{ color: var(--gedaempft); font-size: .9rem; margin: 0; max-width: 46rem; }} |
| 164 | .kacheln {{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.4rem 0; }} |
| 165 | .kachel {{ |
| 166 | background: var(--flaeche); border: 1px solid var(--rand); border-radius: 8px; |
| 167 | padding: .6rem .9rem; min-width: 8.5rem; |
| 168 | }} |
| 169 | .kachel .wert {{ display: block; font-size: 1.4rem; font-weight: 700; |
| 170 | color: var(--akzent); font-variant-numeric: tabular-nums; }} |
| 171 | .kachel .bez {{ display: block; font-size: .8rem; color: var(--gedaempft); }} |
| 172 | nav ul {{ list-style: none; padding: 0; margin: 0 0 2rem; |
| 173 | display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .3rem; }} |
| 174 | nav a {{ text-decoration: none; }} |
| 175 | nav a:hover {{ text-decoration: underline; }} |
| 176 | h2 {{ font-size: 1.25rem; margin: 2.5rem 0 1rem; padding-top: 1rem; |
| 177 | border-top: 1px solid var(--rand); }} |
| 178 | .frage-block {{ |
| 179 | border: 1px solid var(--rand); border-radius: 10px; background: var(--flaeche); |
| 180 | padding: 1rem 1.2rem; margin: 0 0 1rem; |
| 181 | }} |
| 182 | .kopf {{ display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; |
| 183 | font-size: .8rem; color: var(--gedaempft); margin-bottom: .5rem; }} |
| 184 | .kopf .id {{ font-weight: 700; color: var(--text); font-size: .95rem; }} |
| 185 | .typ {{ border: 1px solid var(--rand); border-radius: 99px; padding: .05rem .55rem; }} |
| 186 | .typ-mc {{ border-color: var(--akzent); color: var(--akzent); }} |
| 187 | .frage {{ margin: 0 0 .7rem; font-size: 1.02rem; }} |
| 188 | .mehrfach {{ margin: 0 0 .5rem; font-size: .8rem; color: var(--akzent); font-weight: 600; }} |
| 189 | ul.optionen {{ list-style: none; margin: 0; padding: 0; display: flex; |
| 190 | flex-direction: column; gap: .35rem; }} |
| 191 | ul.optionen li {{ display: grid; grid-template-columns: 1.4rem 1.4rem 1fr; |
| 192 | gap: .4rem; align-items: start; }} |
| 193 | .marke {{ font-weight: 700; }} |
| 194 | li.richtig .marke {{ color: var(--richtig); }} |
| 195 | li.falsch .marke {{ color: var(--falsch); }} |
| 196 | li.richtig .text {{ font-weight: 600; }} |
| 197 | .label {{ color: var(--gedaempft); }} |
| 198 | .antwort {{ border-left: 3px solid var(--akzent); padding: .3rem 0 .3rem .8rem; }} |
| 199 | .antwort .ml {{ display: block; font-size: .75rem; text-transform: uppercase; |
| 200 | letter-spacing: .08em; color: var(--gedaempft); }} |
| 201 | mark {{ background: var(--markierung); color: var(--text); |
| 202 | padding: 0 .15em; border-radius: 3px; font-weight: 600; }} |
| 203 | img {{ max-height: 3.2rem; width: auto; vertical-align: middle; margin: .15rem .4rem .15rem 0; |
| 204 | background: #fff; border-radius: 3px; padding: 2px; }} |
| 205 | .warnung {{ font-size: .82rem; color: var(--falsch); margin: .6rem 0 0; }} |
| 206 | .fehlt {{ color: var(--falsch); font-style: italic; }} |
| 207 | footer {{ margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--rand); |
| 208 | font-size: .85rem; color: var(--gedaempft); }} |
| 209 | </style> |
| 210 | </head> |
| 211 | <body> |
| 212 | <div class="huelle"> |
| 213 | <header> |
| 214 | <h1>Prüfbericht Fragenkatalog</h1> |
| 215 | <p class="quelle">{quelle} — Katalogstand {stand}. Grün markierte Antworten sind |
| 216 | im Original angekreuzt; <mark>hervorgehobene</mark> Stellen sind die Kernelemente |
| 217 | der Musterantworten.</p> |
| 218 | </header> |
| 219 | <div class="kacheln">{kacheln}</div> |
| 220 | <nav aria-label="Abschnitte"><ul>{nav}</ul></nav> |
| 221 | <main> |
| 222 | {inhalt} |
| 223 | </main> |
| 224 | <footer>Automatisch erzeugt aus dem amtlichen Katalog-PDF. Dient der Sichtprüfung |
| 225 | der Extraktion, nicht dem Lernen.</footer> |
| 226 | </div> |
| 227 | </body> |
| 228 | </html> |
| 229 | """ |
| 230 | |
| 231 | |
| 232 | if __name__ == "__main__": |
| 233 | raise SystemExit(main()) |