waffensachkunde

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

/ tools datenschutz_html.py

14,6 KB Rohdatei
tools/datenschutz_html.py — 365 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 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()