waffensachkunde

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

/ tools datenschutz_html.py

15,2 KB Rohdatei
tools/datenschutz_html.py — 376 Zeilen
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 &lt;…&gt;.
97 text = re.sub(
98 r"&lt;(https?://[^\s&]+(?:&amp;[^\s&]+)*)&gt;",
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 Liste, nicht als `<table>`.
127
128 ## Warum keine Tabelle
129
130 Das Redaktionssystem, in dem diese Erklärung erscheint, entfernt
131 `<table>` beim Speichern und lässt nur den Zellinhalt stehen. Am
132 28.08.2026 an der veröffentlichten Seite gemessen: null `<table>`-
133 Elemente, aber der gesamte Zelltext vorhanden – aneinandergeklebt. Aus
134 der Standtabelle wurde „Fassung der Erklärung1.1Stand28.08.2026“. `<ul>`,
135 `<li>`, `<pre>`, `<code>` und `<strong>` überstehen dieselbe Reinigung.
136
137 Eine Tabelle, die als Buchstabenbrei ankommt, ist schlechter als eine
138 Liste, die als Liste ankommt – für Lesende wie für Bildschirmleser. Und
139 eine zweite, tabellenfreie Ausgabefassung nur fürs CMS liefe irgendwann
140 auseinander. Deshalb gibt es nur diese eine Form, und sie funktioniert an
141 beiden Orten.
142
143 ## Wie die beiden Tabellen abgebildet werden
144
145 * **Mit Spaltenbeschriftungen** (Abschnitt 2: „Aussage | Wo Sie es
146 nachsehen | Was Sie finden“): je Zeile ein Listenpunkt, der die
147 Beschriftung vor den Wert setzt – „Aussage: …“. Ohne die Beschriftungen
148 wäre nicht mehr erkennbar, was der zweite Wert bedeutet.
149 * **Ohne Spaltenbeschriftungen** (Abschnitt 13, `| | |`): Das erste Feld
150 *ist* die Beschriftung. Es kommt fett voran, der Rest folgt.
151 """
152
153 def felder(zeile: str) -> list[str]:
154 return [feld.strip() for feld in zeile.strip().strip("|").split("|")]
155
156 kopf = felder(zeilen[0])
157 beschriftet = any(feld for feld in kopf)
158
159 ausgabe = ["<ul>"]
160 for zeile in zeilen[2:]: # Zeile 1 ist die Trennzeile |---|---|
161 werte = felder(zeile)
162 if beschriftet:
163 teile = [
164 f"<strong>{fliesstext(k)}:</strong> {fliesstext(w)}"
165 for k, w in zip(kopf, werte)
166 if w
167 ]
168 ausgabe.append(f"<li>{' — '.join(teile)}</li>")
169 elif werte:
170 rest = " ".join(fliesstext(w) for w in werte[1:] if w)
171 # Die Merkmale sind in der Quelle bereits fett ausgezeichnet
172 # (`**Stand**`). Ein zweites <strong> darum ergäbe verschachtelte
173 # Auszeichnung – sichtbar folgenlos, aber ein Bildschirmleser mit
174 # Betonungsansage sagt es zweimal.
175 roh = werte[0]
176 beschriftung = fliesstext(roh)
177 if not roh.startswith("**"):
178 beschriftung = f"<strong>{beschriftung}</strong>"
179 ausgabe.append(f"<li>{beschriftung}: {rest}</li>")
180 ausgabe.append("</ul>")
181 return ausgabe
182
183
184 #: Ein eingerueckter Aufzaehlungspunkt – die zweite Ebene, die dieser Umsetzer
185 #: nicht kann. Er wuerde sonst in den Punkt darueber hineingezogen und
186 #: verschwaende als eigener Listenpunkt.
187 UNTERPUNKT = re.compile(r"^\s+[-*]\s")
188
189 #: Dasselbe Muster, aus der Sicht der Fortsetzungszeile: Nur eine Zeile, die
190 #: KEIN Unterpunkt ist, darf an den vorigen Listenpunkt angehaengt werden.
191 FORTSETZUNG_KEIN_UNTERPUNKT = UNTERPUNKT
192
193 #: Kursivauszeichnung – ein einzelnes Sternchen um ein Wort. Doppelte (fett)
194 #: bleiben aussen vor; der Umsetzer kennt nur die.
195 KURSIV = re.compile(r"(?<![*\w])\*(?!\*)[^*]+\*(?![*\w])")
196
197
198 def umsetzen(markdown: str) -> str:
199 zeilen = markdown.split("\n")
200 ausgabe: list[str] = []
201 absatz: list[str] = []
202 liste: list[str] = []
203 index = 0
204
205 def absatz_schliessen() -> None:
206 if absatz:
207 ausgabe.append(f"<p>{fliesstext(' '.join(absatz))}</p>")
208 absatz.clear()
209
210 def liste_schliessen() -> None:
211 if liste:
212 # `.extend()` und nicht `+=`: Das Zuweisen machte `ausgabe` zu
213 # einer Ortsvariablen dieser inneren Funktion, und der Zugriff
214 # schlüge fehl, bevor er etwas anhängen könnte.
215 ausgabe.append("<ul>")
216 ausgabe.extend(f"<li>{fliesstext(punkt)}</li>" for punkt in liste)
217 ausgabe.append("</ul>")
218 liste.clear()
219
220 while index < len(zeilen):
221 zeile = zeilen[index]
222 blank = zeile.strip() == ""
223
224 if zeile.startswith("```"):
225 absatz_schliessen()
226 liste_schliessen()
227 index += 1
228 inhalt: list[str] = []
229 while index < len(zeilen) and not zeilen[index].startswith("```"):
230 inhalt.append(zeilen[index])
231 index += 1
232 ausgabe.append(f"<pre><code>{maskieren(chr(10).join(inhalt))}</code></pre>")
233 index += 1
234 continue
235
236 if zeile.startswith("|"):
237 absatz_schliessen()
238 liste_schliessen()
239 block: list[str] = []
240 while index < len(zeilen) and zeilen[index].startswith("|"):
241 block.append(zeilen[index])
242 index += 1
243 ausgabe += tabelle(block)
244 continue
245
246 if blank:
247 absatz_schliessen()
248 liste_schliessen()
249 elif zeile.startswith("### "):
250 absatz_schliessen()
251 liste_schliessen()
252 text = fliesstext(zeile[4:])
253 ausgabe.append(f'<h3 id="{kennung(text)}">{text}</h3>')
254 elif zeile.startswith("## "):
255 absatz_schliessen()
256 liste_schliessen()
257 text = fliesstext(zeile[3:])
258 ausgabe.append(f'<h2 id="{kennung(text)}">{text}</h2>')
259 elif zeile.startswith("# "):
260 absatz_schliessen()
261 liste_schliessen()
262 ausgabe.append(f"<h1>{fliesstext(zeile[2:])}</h1>")
263 elif zeile.strip() == "---":
264 absatz_schliessen()
265 liste_schliessen()
266 # Die Trennlinien gliedern die Abschnitte. Als <hr> wären sie eine
267 # zweite, tonlose Gliederung neben den Überschriften; Bildschirm-
268 # leser lesen sie mit. Die Überschriften tragen die Gliederung
269 # bereits, deshalb entfallen sie.
270 elif zeile.startswith("- "):
271 absatz_schliessen()
272 liste.append(zeile[2:].strip())
273 elif liste and zeile.startswith(" ") and not FORTSETZUNG_KEIN_UNTERPUNKT.match(zeile):
274 liste[-1] += " " + zeile.strip() # umgebrochener Listenpunkt
275 else:
276 # Was der Umsetzer nicht kann, muss er melden – so sagt es der
277 # Modulkopf zu.
278 #
279 # Bis Fassung 0.24.1 standen hier nur zwei Formen, und drei
280 # weitere fielen still durch. Nachgemessen an `umsetzen()`:
281 # „- Punkt\n - Unterpunkt" zog den Unterpunkt in den
282 # Elterneintrag hinein und liess ihn als eigenen Punkt
283 # verschwinden; „#### Vierte Ebene" erschien mit den vier Rauten
284 # als Text und ohne Ebene in der Gliederung (Barriere nach
285 # WCAG 1.3.1); „*kursiv*" zeigte die Sternchen.
286 #
287 # Die Seite ist die veroeffentlichte Datenschutzerklaerung – dort
288 # ist eine stumm verschluckte Auszeichnung schlimmer als ein
289 # Abbruch beim Erzeugen.
290 for unerwartet, was in (
291 ("> ", "Blockzitat"),
292 ("* ", "Aufzählung mit *"),
293 ("#### ", "Überschrift der vierten Ebene"),
294 ):
295 if zeile.lstrip().startswith(unerwartet):
296 sys.exit(
297 f"Zeile {index + 1}: {was} gefunden. Diese Auszeichnung "
298 "setzt der Umsetzer nicht um; sie würde stillschweigend "
299 "als Absatz erscheinen. Entweder im Dokument vermeiden "
300 "oder hier ergänzen."
301 )
302 if UNTERPUNKT.match(zeile):
303 sys.exit(
304 f"Zeile {index + 1}: verschachtelte Aufzählung gefunden. "
305 "Der Umsetzer kennt nur eine Ebene; der Unterpunkt würde "
306 "in den Punkt darüber hineingezogen und verschwände. "
307 "Entweder im Dokument vermeiden oder hier ergänzen."
308 )
309 if KURSIV.search(zeile):
310 sys.exit(
311 f"Zeile {index + 1}: Kursivauszeichnung gefunden. Der "
312 "Umsetzer kennt nur **fett**; die Sternchen erschienen als "
313 "Zeichen auf der Seite. Entweder im Dokument vermeiden "
314 "oder hier ergänzen."
315 )
316 absatz.append(zeile.strip())
317 index += 1
318
319 absatz_schliessen()
320 liste_schliessen()
321 return "\n".join(ausgabe)
322
323
324 STIL = """
325 :root { color-scheme: light dark; }
326 body {
327 max-width: 46rem; margin: 0 auto; padding: 2rem 1rem;
328 font-family: system-ui, sans-serif; line-height: 1.6;
329 }
330 h1, h2, h3 { line-height: 1.25; }
331 h2 { margin-top: 2.5rem; }
332 table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
333 th, td { border: 1px solid; padding: 0.4rem 0.6rem; text-align: left;
334 vertical-align: top; }
335 pre { padding: 0.6rem 0.8rem; overflow-x: auto; border: 1px solid; }
336 code { font-family: ui-monospace, monospace; }
337 a { color: inherit; }
338 """
339
340
341 def main() -> None:
342 inhalt = umsetzen(veroeffentlichungsteil(QUELLE.read_text(encoding="utf-8")))
343 seite = (
344 "<!doctype html>\n"
345 '<html lang="de">\n'
346 "<head>\n"
347 '<meta charset="utf-8">\n'
348 '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
349 f"<title>{TITEL}</title>\n"
350 f"<style>{STIL}</style>\n"
351 "</head>\n"
352 "<body>\n"
353 "<!-- ERZEUGT AUS docs/datenschutz.md – NICHT HIER BEARBEITEN.\n"
354 " Neu erzeugen mit: python tools/datenschutz_html.py\n"
355 "\n"
356 " Zum Einsetzen in ein Redaktionssystem genügt der Bereich\n"
357 " zwischen den beiden Marken AUSSCHNITT-ANFANG und -ENDE. Er\n"
358 " enthält keine eigene Gestaltung und übernimmt die der Seite. -->\n"
359 "<!-- AUSSCHNITT-ANFANG -->\n"
360 f"{inhalt}\n"
361 "<!-- AUSSCHNITT-ENDE -->\n"
362 "</body>\n"
363 "</html>\n"
364 )
365 # `newline="\n"` ausdrücklich: Ohne die Angabe übersetzt Python unter
366 # Windows jedes `\n` in ein `\r\n`. `.gitattributes` verlangt aber
367 # `* text=auto eol=lf`, und der Arbeitsbaum soll dieselben Zeilenenden
368 # tragen wie das Archiv – sonst meldet Prettier eine Datei, an der
369 # inhaltlich nichts falsch ist.
370 with ZIEL.open("w", encoding="utf-8", newline="\n") as datei:
371 datei.write(seite)
372 print(f"{ZIEL.relative_to(WURZEL)} geschrieben ({len(seite):,} Zeichen).")
373
374
375 if __name__ == "__main__":
376 main()