waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | """Erzeugt aus `docs/datenschutz.md` die veröffentlichungsfertige HTML-Fassung. |
| 2 | |
| 3 | ## Warum es dieses Werkzeug gibt |
| 4 | |
| 5 | Der Microsoft Store verlangt die Datenschutzerklärung unter einer öffentlich |
| 6 | erreichbaren Adresse. Die Quelle ist Markdown und bleibt es — dort steht sie |
| 7 | neben dem Quelltext, auf den sie sich beruft, und wird mit ihm zusammen |
| 8 | geändert. Was ins Netz geht, ist HTML. |
| 9 | |
| 10 | Von Hand übertragen wird das genau einmal gut und danach nie wieder: Bei der |
| 11 | zweiten Änderung weicht die Webseite vom Dokument ab, und dann behauptet eine |
| 12 | Datenschutzerklärung etwas anderes als die Datei, die sie belegt. Deshalb ein |
| 13 | Umsetzer statt einer Handarbeit — er ist wiederholbar, und die Webfassung ist |
| 14 | jederzeit neu erzeugbar statt nachgepflegt. |
| 15 | |
| 16 | ## Warum ein eigener statt einer Bibliothek |
| 17 | |
| 18 | `python-markdown` und `marked` sind hier nicht installiert, und für ein |
| 19 | einzelnes Dokument eine Abhängigkeit aufzunehmen, die dann im Gate mitgeprüft |
| 20 | und in der Lizenzübersicht geführt werden müsste, steht in keinem Verhältnis. |
| 21 | Umgesetzt wird deshalb genau der Vorrat an Auszeichnungen, den dieses eine |
| 22 | Dokument benutzt — am Bestand nachgezählt, nicht geraten: eine H1, dreizehn |
| 23 | H2, neunzehn H3, einunddreißig Listenpunkte, zwei Tabellen, drei |
| 24 | Codeblöcke, dreizehn Trennlinien, achtundfünfzig Code-Auszeichnungen im |
| 25 | Fließtext, siebzig Fettungen, ein Link und eine Adressangabe in spitzen |
| 26 | Klammern. Kursivschrift, verschachtelte Listen und Blockzitate kommen nicht |
| 27 | vor; taucht eines davon auf, meldet der Umsetzer es, statt es stillschweigend |
| 28 | fallen zu lassen. |
| 29 | |
| 30 | ## Was hier nicht mitgeht |
| 31 | |
| 32 | Alles oberhalb der doppelten Trennlinie in `docs/datenschutz.md`. Das ist der |
| 33 | Vorspann für den Betreiber — Arbeitsanweisung, keine Erklärung. Er hat auf |
| 34 | einer Webseite nichts verloren, und der Umsetzer bricht ab, wenn er die |
| 35 | Trennlinie nicht findet, statt versehentlich alles mitzunehmen. |
| 36 | |
| 37 | Aufruf: `python tools/datenschutz_html.py` |
| 38 | """ |
| 39 | |
| 40 | from __future__ import annotations |
| 41 | |
| 42 | import re |
| 43 | import sys |
| 44 | from pathlib import Path |
| 45 | |
| 46 | WURZEL = Path(__file__).resolve().parent.parent |
| 47 | QUELLE = WURZEL / "docs" / "datenschutz.md" |
| 48 | ZIEL = WURZEL / "docs" / "datenschutz.html" |
| 49 | |
| 50 | # Die doppelte Trennlinie trennt den Betreibervorspann vom Erklärungstext. |
| 51 | TRENNER = "---\n---\n" |
| 52 | |
| 53 | TITEL = "Datenschutzerklärung zur Waffensachkunde-Lernsoftware" |
| 54 | |
| 55 | |
| 56 | def veroeffentlichungsteil(markdown: str) -> str: |
| 57 | """Alles unterhalb der doppelten Trennlinie – oder ein Abbruch.""" |
| 58 | stelle = markdown.find(TRENNER) |
| 59 | if stelle < 0: |
| 60 | sys.exit( |
| 61 | "Die doppelte Trennlinie '---/---' fehlt in docs/datenschutz.md. " |
| 62 | "Ohne sie ist nicht feststellbar, wo der Betreibervorspann endet; " |
| 63 | "der Umsetzer bricht lieber ab, als ihn mitzuveröffentlichen." |
| 64 | ) |
| 65 | return markdown[stelle + len(TRENNER) :].strip("\n") |
| 66 | |
| 67 | |
| 68 | def maskieren(text: str) -> str: |
| 69 | """Zeichen, die in HTML eine Bedeutung haben, unschädlich machen. |
| 70 | |
| 71 | Zuerst und ausnahmslos: Das Dokument enthält an mehreren Stellen spitze |
| 72 | Klammern als Text (`<Ihr Name>`, `<Zeitstempel>`, `<code>`). Würden sie |
| 73 | als Auszeichnung durchgereicht, verschwänden sie beim Anzeigen spurlos – |
| 74 | aus einem Dateinamen mit Platzhalter würde ein halber Dateiname. |
| 75 | """ |
| 76 | return text.replace("&", "&").replace("<", "<").replace(">", ">") |
| 77 | |
| 78 | |
| 79 | def fliesstext(text: str) -> str: |
| 80 | """Auszeichnungen innerhalb einer Zeile. |
| 81 | |
| 82 | Reihenfolge ist wesentlich: Code-Auszeichnungen zuerst und danach |
| 83 | unangetastet, sonst würde ein `**` innerhalb eines Codeschnipsels als |
| 84 | Fettung gelesen. Die Schnipsel werden deshalb herausgenommen, der Rest |
| 85 | wird bearbeitet, und am Ende kommen sie zurück. |
| 86 | """ |
| 87 | schnipsel: list[str] = [] |
| 88 | |
| 89 | def beiseite(treffer: re.Match[str]) -> str: |
| 90 | schnipsel.append(treffer.group(1)) |
| 91 | return f"\x00{len(schnipsel) - 1}\x00" |
| 92 | |
| 93 | text = re.sub(r"`([^`]+)`", beiseite, text) |
| 94 | text = maskieren(text) |
| 95 | |
| 96 | # Adressangabe in spitzen Klammern – nach dem Maskieren also <…>. |
| 97 | text = re.sub( |
| 98 | r"<(https?://[^\s&]+(?:&[^\s&]+)*)>", |
| 99 | lambda t: f'<a href="{t.group(1)}">{t.group(1)}</a>', |
| 100 | text, |
| 101 | ) |
| 102 | # [Beschriftung](Adresse) |
| 103 | text = re.sub( |
| 104 | r"\[([^\]]+)\]\((https?://[^)]+)\)", |
| 105 | lambda t: f'<a href="{t.group(2)}">{t.group(1)}</a>', |
| 106 | text, |
| 107 | ) |
| 108 | text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text) |
| 109 | |
| 110 | for nummer, inhalt in enumerate(schnipsel): |
| 111 | text = text.replace(f"\x00{nummer}\x00", f"<code>{maskieren(inhalt)}</code>") |
| 112 | return text |
| 113 | |
| 114 | |
| 115 | def kennung(ueberschrift: str) -> str: |
| 116 | """Sprungziel aus einer Überschrift – damit Abschnitte verlinkbar sind.""" |
| 117 | roh = re.sub(r"<[^>]+>", "", ueberschrift).lower() |
| 118 | ersetzungen = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} |
| 119 | for zeichen, ersatz in ersetzungen.items(): |
| 120 | roh = roh.replace(zeichen, ersatz) |
| 121 | roh = re.sub(r"[^a-z0-9]+", "-", roh).strip("-") |
| 122 | return roh or "abschnitt" |
| 123 | |
| 124 | |
| 125 | def tabelle(zeilen: list[str]) -> list[str]: |
| 126 | """Eine Pipe-Tabelle – ausgegeben als echtes `<table>`. |
| 127 | |
| 128 | ## Warum jetzt eine Tabelle |
| 129 | |
| 130 | Frühere Fassungen gaben Tabellen als `<ul>`-Liste aus, weil der damalige |
| 131 | WYSIWYG-Editor der Zielseite (Quill) `<table>` beim Speichern verwarf und |
| 132 | nur den aneinandergeklebten Zellinhalt stehen ließ. Die Seite benutzt |
| 133 | inzwischen TinyMCE, das Tabellen erhält, und speichert den Inhalt als rohes |
| 134 | HTML. Damit steht der Tabellenauszeichnung nichts mehr im Weg, und eine |
| 135 | Tabelle wird als Tabelle ausgegeben – für Lesende wie für Bildschirmleser |
| 136 | die klarere Form. Das Tabellen-Styling bringen sowohl die Zielseite als |
| 137 | auch der Style-Block dieser Datei (`table`, `th`, `td`) bereits mit. |
| 138 | |
| 139 | ## Wie die beiden Tabellen abgebildet werden |
| 140 | |
| 141 | * **Mit Spaltenbeschriftungen** (Abschnitt 2): die Kopfzeile wird zu |
| 142 | `<thead>` mit `<th>`, die übrigen Zeilen zu `<tbody>` mit `<td>`. |
| 143 | * **Ohne Spaltenbeschriftungen** (Abschnitt 13, `| | |`): kein `<thead>`, |
| 144 | nur `<tbody>`. Das erste Feld ist in der Quelle bereits fett |
| 145 | ausgezeichnet (`**Stand**`) und bleibt es. |
| 146 | """ |
| 147 | |
| 148 | def felder(zeile: str) -> list[str]: |
| 149 | return [feld.strip() for feld in zeile.strip().strip("|").split("|")] |
| 150 | |
| 151 | kopf = felder(zeilen[0]) |
| 152 | beschriftet = any(feld for feld in kopf) |
| 153 | |
| 154 | ausgabe = ["<table>"] |
| 155 | if beschriftet: |
| 156 | ausgabe.append("<thead>") |
| 157 | ausgabe.append( |
| 158 | "<tr>" + "".join(f"<th>{fliesstext(k)}</th>" for k in kopf) + "</tr>" |
| 159 | ) |
| 160 | ausgabe.append("</thead>") |
| 161 | ausgabe.append("<tbody>") |
| 162 | for zeile in zeilen[2:]: # Zeile 1 ist die Trennzeile |---|---| |
| 163 | werte = felder(zeile) |
| 164 | if not any(werte): |
| 165 | continue # Leerzeilen (z. B. der leere Kopf in Abschnitt 13) überspringen |
| 166 | zellen = "".join(f"<td>{fliesstext(w)}</td>" for w in werte) |
| 167 | ausgabe.append(f"<tr>{zellen}</tr>") |
| 168 | ausgabe.append("</tbody>") |
| 169 | ausgabe.append("</table>") |
| 170 | return ausgabe |
| 171 | |
| 172 | |
| 173 | #: Ein eingerueckter Aufzaehlungspunkt – die zweite Ebene, die dieser Umsetzer |
| 174 | #: nicht kann. Er wuerde sonst in den Punkt darueber hineingezogen und |
| 175 | #: verschwaende als eigener Listenpunkt. |
| 176 | UNTERPUNKT = re.compile(r"^\s+[-*]\s") |
| 177 | |
| 178 | #: Dasselbe Muster, aus der Sicht der Fortsetzungszeile: Nur eine Zeile, die |
| 179 | #: KEIN Unterpunkt ist, darf an den vorigen Listenpunkt angehaengt werden. |
| 180 | FORTSETZUNG_KEIN_UNTERPUNKT = UNTERPUNKT |
| 181 | |
| 182 | #: Kursivauszeichnung – ein einzelnes Sternchen um ein Wort. Doppelte (fett) |
| 183 | #: bleiben aussen vor; der Umsetzer kennt nur die. |
| 184 | KURSIV = re.compile(r"(?<![*\w])\*(?!\*)[^*]+\*(?![*\w])") |
| 185 | |
| 186 | |
| 187 | def umsetzen(markdown: str) -> str: |
| 188 | zeilen = markdown.split("\n") |
| 189 | ausgabe: list[str] = [] |
| 190 | absatz: list[str] = [] |
| 191 | liste: list[str] = [] |
| 192 | index = 0 |
| 193 | |
| 194 | def absatz_schliessen() -> None: |
| 195 | if absatz: |
| 196 | ausgabe.append(f"<p>{fliesstext(' '.join(absatz))}</p>") |
| 197 | absatz.clear() |
| 198 | |
| 199 | def liste_schliessen() -> None: |
| 200 | if liste: |
| 201 | # `.extend()` und nicht `+=`: Das Zuweisen machte `ausgabe` zu |
| 202 | # einer Ortsvariablen dieser inneren Funktion, und der Zugriff |
| 203 | # schlüge fehl, bevor er etwas anhängen könnte. |
| 204 | ausgabe.append("<ul>") |
| 205 | ausgabe.extend(f"<li>{fliesstext(punkt)}</li>" for punkt in liste) |
| 206 | ausgabe.append("</ul>") |
| 207 | liste.clear() |
| 208 | |
| 209 | while index < len(zeilen): |
| 210 | zeile = zeilen[index] |
| 211 | blank = zeile.strip() == "" |
| 212 | |
| 213 | if zeile.startswith("```"): |
| 214 | absatz_schliessen() |
| 215 | liste_schliessen() |
| 216 | index += 1 |
| 217 | inhalt: list[str] = [] |
| 218 | while index < len(zeilen) and not zeilen[index].startswith("```"): |
| 219 | inhalt.append(zeilen[index]) |
| 220 | index += 1 |
| 221 | ausgabe.append(f"<pre><code>{maskieren(chr(10).join(inhalt))}</code></pre>") |
| 222 | index += 1 |
| 223 | continue |
| 224 | |
| 225 | if zeile.startswith("|"): |
| 226 | absatz_schliessen() |
| 227 | liste_schliessen() |
| 228 | block: list[str] = [] |
| 229 | while index < len(zeilen) and zeilen[index].startswith("|"): |
| 230 | block.append(zeilen[index]) |
| 231 | index += 1 |
| 232 | ausgabe += tabelle(block) |
| 233 | continue |
| 234 | |
| 235 | if blank: |
| 236 | absatz_schliessen() |
| 237 | liste_schliessen() |
| 238 | elif zeile.startswith("### "): |
| 239 | absatz_schliessen() |
| 240 | liste_schliessen() |
| 241 | text = fliesstext(zeile[4:]) |
| 242 | ausgabe.append(f'<h3 id="{kennung(text)}">{text}</h3>') |
| 243 | elif zeile.startswith("## "): |
| 244 | absatz_schliessen() |
| 245 | liste_schliessen() |
| 246 | text = fliesstext(zeile[3:]) |
| 247 | ausgabe.append(f'<h2 id="{kennung(text)}">{text}</h2>') |
| 248 | elif zeile.startswith("# "): |
| 249 | absatz_schliessen() |
| 250 | liste_schliessen() |
| 251 | ausgabe.append(f"<h1>{fliesstext(zeile[2:])}</h1>") |
| 252 | elif zeile.strip() == "---": |
| 253 | absatz_schliessen() |
| 254 | liste_schliessen() |
| 255 | # Die Trennlinien gliedern die Abschnitte. Als <hr> wären sie eine |
| 256 | # zweite, tonlose Gliederung neben den Überschriften; Bildschirm- |
| 257 | # leser lesen sie mit. Die Überschriften tragen die Gliederung |
| 258 | # bereits, deshalb entfallen sie. |
| 259 | elif zeile.startswith("- "): |
| 260 | absatz_schliessen() |
| 261 | liste.append(zeile[2:].strip()) |
| 262 | elif liste and zeile.startswith(" ") and not FORTSETZUNG_KEIN_UNTERPUNKT.match(zeile): |
| 263 | liste[-1] += " " + zeile.strip() # umgebrochener Listenpunkt |
| 264 | else: |
| 265 | # Was der Umsetzer nicht kann, muss er melden – so sagt es der |
| 266 | # Modulkopf zu. |
| 267 | # |
| 268 | # Bis Fassung 0.24.1 standen hier nur zwei Formen, und drei |
| 269 | # weitere fielen still durch. Nachgemessen an `umsetzen()`: |
| 270 | # „- Punkt\n - Unterpunkt" zog den Unterpunkt in den |
| 271 | # Elterneintrag hinein und liess ihn als eigenen Punkt |
| 272 | # verschwinden; „#### Vierte Ebene" erschien mit den vier Rauten |
| 273 | # als Text und ohne Ebene in der Gliederung (Barriere nach |
| 274 | # WCAG 1.3.1); „*kursiv*" zeigte die Sternchen. |
| 275 | # |
| 276 | # Die Seite ist die veroeffentlichte Datenschutzerklaerung – dort |
| 277 | # ist eine stumm verschluckte Auszeichnung schlimmer als ein |
| 278 | # Abbruch beim Erzeugen. |
| 279 | for unerwartet, was in ( |
| 280 | ("> ", "Blockzitat"), |
| 281 | ("* ", "Aufzählung mit *"), |
| 282 | ("#### ", "Überschrift der vierten Ebene"), |
| 283 | ): |
| 284 | if zeile.lstrip().startswith(unerwartet): |
| 285 | sys.exit( |
| 286 | f"Zeile {index + 1}: {was} gefunden. Diese Auszeichnung " |
| 287 | "setzt der Umsetzer nicht um; sie würde stillschweigend " |
| 288 | "als Absatz erscheinen. Entweder im Dokument vermeiden " |
| 289 | "oder hier ergänzen." |
| 290 | ) |
| 291 | if UNTERPUNKT.match(zeile): |
| 292 | sys.exit( |
| 293 | f"Zeile {index + 1}: verschachtelte Aufzählung gefunden. " |
| 294 | "Der Umsetzer kennt nur eine Ebene; der Unterpunkt würde " |
| 295 | "in den Punkt darüber hineingezogen und verschwände. " |
| 296 | "Entweder im Dokument vermeiden oder hier ergänzen." |
| 297 | ) |
| 298 | if KURSIV.search(zeile): |
| 299 | sys.exit( |
| 300 | f"Zeile {index + 1}: Kursivauszeichnung gefunden. Der " |
| 301 | "Umsetzer kennt nur **fett**; die Sternchen erschienen als " |
| 302 | "Zeichen auf der Seite. Entweder im Dokument vermeiden " |
| 303 | "oder hier ergänzen." |
| 304 | ) |
| 305 | absatz.append(zeile.strip()) |
| 306 | index += 1 |
| 307 | |
| 308 | absatz_schliessen() |
| 309 | liste_schliessen() |
| 310 | return "\n".join(ausgabe) |
| 311 | |
| 312 | |
| 313 | STIL = """ |
| 314 | :root { color-scheme: light dark; } |
| 315 | body { |
| 316 | max-width: 46rem; margin: 0 auto; padding: 2rem 1rem; |
| 317 | font-family: system-ui, sans-serif; line-height: 1.6; |
| 318 | } |
| 319 | h1, h2, h3 { line-height: 1.25; } |
| 320 | h2 { margin-top: 2.5rem; } |
| 321 | table { border-collapse: collapse; width: 100%; margin: 1rem 0; } |
| 322 | th, td { border: 1px solid; padding: 0.4rem 0.6rem; text-align: left; |
| 323 | vertical-align: top; } |
| 324 | pre { padding: 0.6rem 0.8rem; overflow-x: auto; border: 1px solid; } |
| 325 | code { font-family: ui-monospace, monospace; } |
| 326 | a { color: inherit; } |
| 327 | """ |
| 328 | |
| 329 | |
| 330 | def main() -> None: |
| 331 | inhalt = umsetzen(veroeffentlichungsteil(QUELLE.read_text(encoding="utf-8"))) |
| 332 | seite = ( |
| 333 | "<!doctype html>\n" |
| 334 | '<html lang="de">\n' |
| 335 | "<head>\n" |
| 336 | '<meta charset="utf-8">\n' |
| 337 | '<meta name="viewport" content="width=device-width, initial-scale=1">\n' |
| 338 | f"<title>{TITEL}</title>\n" |
| 339 | f"<style>{STIL}</style>\n" |
| 340 | "</head>\n" |
| 341 | "<body>\n" |
| 342 | "<!-- ERZEUGT AUS docs/datenschutz.md – NICHT HIER BEARBEITEN.\n" |
| 343 | " Neu erzeugen mit: python tools/datenschutz_html.py\n" |
| 344 | "\n" |
| 345 | " Zum Einsetzen in ein Redaktionssystem genügt der Bereich\n" |
| 346 | " zwischen den beiden Marken AUSSCHNITT-ANFANG und -ENDE. Er\n" |
| 347 | " enthält keine eigene Gestaltung und übernimmt die der Seite. -->\n" |
| 348 | "<!-- AUSSCHNITT-ANFANG -->\n" |
| 349 | f"{inhalt}\n" |
| 350 | "<!-- AUSSCHNITT-ENDE -->\n" |
| 351 | "</body>\n" |
| 352 | "</html>\n" |
| 353 | ) |
| 354 | # `newline="\n"` ausdrücklich: Ohne die Angabe übersetzt Python unter |
| 355 | # Windows jedes `\n` in ein `\r\n`. `.gitattributes` verlangt aber |
| 356 | # `* text=auto eol=lf`, und der Arbeitsbaum soll dieselben Zeilenenden |
| 357 | # tragen wie das Archiv – sonst meldet Prettier eine Datei, an der |
| 358 | # inhaltlich nichts falsch ist. |
| 359 | with ZIEL.open("w", encoding="utf-8", newline="\n") as datei: |
| 360 | datei.write(seite) |
| 361 | print(f"{ZIEL.relative_to(WURZEL)} geschrieben ({len(seite):,} Zeichen).") |
| 362 | |
| 363 | |
| 364 | if __name__ == "__main__": |
| 365 | main() |