"""Erzeugt aus `docs/datenschutz.md` die veröffentlichungsfertige HTML-Fassung. ## Warum es dieses Werkzeug gibt Der Microsoft Store verlangt die Datenschutzerklärung unter einer öffentlich erreichbaren Adresse. Die Quelle ist Markdown und bleibt es — dort steht sie neben dem Quelltext, auf den sie sich beruft, und wird mit ihm zusammen geändert. Was ins Netz geht, ist HTML. Von Hand übertragen wird das genau einmal gut und danach nie wieder: Bei der zweiten Änderung weicht die Webseite vom Dokument ab, und dann behauptet eine Datenschutzerklärung etwas anderes als die Datei, die sie belegt. Deshalb ein Umsetzer statt einer Handarbeit — er ist wiederholbar, und die Webfassung ist jederzeit neu erzeugbar statt nachgepflegt. ## Warum ein eigener statt einer Bibliothek `python-markdown` und `marked` sind hier nicht installiert, und für ein einzelnes Dokument eine Abhängigkeit aufzunehmen, die dann im Gate mitgeprüft und in der Lizenzübersicht geführt werden müsste, steht in keinem Verhältnis. Umgesetzt wird deshalb genau der Vorrat an Auszeichnungen, den dieses eine Dokument benutzt — am Bestand nachgezählt, nicht geraten: eine H1, dreizehn H2, neunzehn H3, einunddreißig Listenpunkte, zwei Tabellen, drei Codeblöcke, dreizehn Trennlinien, achtundfünfzig Code-Auszeichnungen im Fließtext, siebzig Fettungen, ein Link und eine Adressangabe in spitzen Klammern. Kursivschrift, verschachtelte Listen und Blockzitate kommen nicht vor; taucht eines davon auf, meldet der Umsetzer es, statt es stillschweigend fallen zu lassen. ## Was hier nicht mitgeht Alles oberhalb der doppelten Trennlinie in `docs/datenschutz.md`. Das ist der Vorspann für den Betreiber — Arbeitsanweisung, keine Erklärung. Er hat auf einer Webseite nichts verloren, und der Umsetzer bricht ab, wenn er die Trennlinie nicht findet, statt versehentlich alles mitzunehmen. Aufruf: `python tools/datenschutz_html.py` """ from __future__ import annotations import re import sys from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent QUELLE = WURZEL / "docs" / "datenschutz.md" ZIEL = WURZEL / "docs" / "datenschutz.html" # Die doppelte Trennlinie trennt den Betreibervorspann vom Erklärungstext. TRENNER = "---\n---\n" TITEL = "Datenschutzerklärung zur Waffensachkunde-Lernsoftware" def veroeffentlichungsteil(markdown: str) -> str: """Alles unterhalb der doppelten Trennlinie – oder ein Abbruch.""" stelle = markdown.find(TRENNER) if stelle < 0: sys.exit( "Die doppelte Trennlinie '---/---' fehlt in docs/datenschutz.md. " "Ohne sie ist nicht feststellbar, wo der Betreibervorspann endet; " "der Umsetzer bricht lieber ab, als ihn mitzuveröffentlichen." ) return markdown[stelle + len(TRENNER) :].strip("\n") def maskieren(text: str) -> str: """Zeichen, die in HTML eine Bedeutung haben, unschädlich machen. Zuerst und ausnahmslos: Das Dokument enthält an mehreren Stellen spitze Klammern als Text (``, ``, ``). Würden sie als Auszeichnung durchgereicht, verschwänden sie beim Anzeigen spurlos – aus einem Dateinamen mit Platzhalter würde ein halber Dateiname. """ return text.replace("&", "&").replace("<", "<").replace(">", ">") def fliesstext(text: str) -> str: """Auszeichnungen innerhalb einer Zeile. Reihenfolge ist wesentlich: Code-Auszeichnungen zuerst und danach unangetastet, sonst würde ein `**` innerhalb eines Codeschnipsels als Fettung gelesen. Die Schnipsel werden deshalb herausgenommen, der Rest wird bearbeitet, und am Ende kommen sie zurück. """ schnipsel: list[str] = [] def beiseite(treffer: re.Match[str]) -> str: schnipsel.append(treffer.group(1)) return f"\x00{len(schnipsel) - 1}\x00" text = re.sub(r"`([^`]+)`", beiseite, text) text = maskieren(text) # Adressangabe in spitzen Klammern – nach dem Maskieren also <…>. text = re.sub( r"<(https?://[^\s&]+(?:&[^\s&]+)*)>", lambda t: f'{t.group(1)}', text, ) # [Beschriftung](Adresse) text = re.sub( r"\[([^\]]+)\]\((https?://[^)]+)\)", lambda t: f'{t.group(1)}', text, ) text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) for nummer, inhalt in enumerate(schnipsel): text = text.replace(f"\x00{nummer}\x00", f"{maskieren(inhalt)}") return text def kennung(ueberschrift: str) -> str: """Sprungziel aus einer Überschrift – damit Abschnitte verlinkbar sind.""" roh = re.sub(r"<[^>]+>", "", ueberschrift).lower() ersetzungen = {"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} for zeichen, ersatz in ersetzungen.items(): roh = roh.replace(zeichen, ersatz) roh = re.sub(r"[^a-z0-9]+", "-", roh).strip("-") return roh or "abschnitt" def tabelle(zeilen: list[str]) -> list[str]: """Eine Pipe-Tabelle – ausgegeben als echtes ``. ## Warum jetzt eine Tabelle Frühere Fassungen gaben Tabellen als `
    `-Liste aus, weil der damalige WYSIWYG-Editor der Zielseite (Quill) `
` beim Speichern verwarf und nur den aneinandergeklebten Zellinhalt stehen ließ. Die Seite benutzt inzwischen TinyMCE, das Tabellen erhält, und speichert den Inhalt als rohes HTML. Damit steht der Tabellenauszeichnung nichts mehr im Weg, und eine Tabelle wird als Tabelle ausgegeben – für Lesende wie für Bildschirmleser die klarere Form. Das Tabellen-Styling bringen sowohl die Zielseite als auch der Style-Block dieser Datei (`table`, `th`, `td`) bereits mit. ## Wie die beiden Tabellen abgebildet werden * **Mit Spaltenbeschriftungen** (Abschnitt 2): die Kopfzeile wird zu `` mit `` mit ``, nur ``. Das erste Feld ist in der Quelle bereits fett ausgezeichnet (`**Stand**`) und bleibt es. """ def felder(zeile: str) -> list[str]: return [feld.strip() for feld in zeile.strip().strip("|").split("|")] kopf = felder(zeilen[0]) beschriftet = any(feld for feld in kopf) ausgabe = ["
`, die übrigen Zeilen zu `
`. * **Ohne Spaltenbeschriftungen** (Abschnitt 13, `| | |`): kein `
"] if beschriftet: ausgabe.append("") ausgabe.append( "" + "".join(f"" for k in kopf) + "" ) ausgabe.append("") ausgabe.append("") for zeile in zeilen[2:]: # Zeile 1 ist die Trennzeile |---|---| werte = felder(zeile) if not any(werte): continue # Leerzeilen (z. B. der leere Kopf in Abschnitt 13) überspringen zellen = "".join(f"" for w in werte) ausgabe.append(f"{zellen}") ausgabe.append("") ausgabe.append("
{fliesstext(k)}
{fliesstext(w)}
") return ausgabe #: Ein eingerueckter Aufzaehlungspunkt – die zweite Ebene, die dieser Umsetzer #: nicht kann. Er wuerde sonst in den Punkt darueber hineingezogen und #: verschwaende als eigener Listenpunkt. UNTERPUNKT = re.compile(r"^\s+[-*]\s") #: Dasselbe Muster, aus der Sicht der Fortsetzungszeile: Nur eine Zeile, die #: KEIN Unterpunkt ist, darf an den vorigen Listenpunkt angehaengt werden. FORTSETZUNG_KEIN_UNTERPUNKT = UNTERPUNKT #: Kursivauszeichnung – ein einzelnes Sternchen um ein Wort. Doppelte (fett) #: bleiben aussen vor; der Umsetzer kennt nur die. KURSIV = re.compile(r"(? str: zeilen = markdown.split("\n") ausgabe: list[str] = [] absatz: list[str] = [] liste: list[str] = [] index = 0 def absatz_schliessen() -> None: if absatz: ausgabe.append(f"

{fliesstext(' '.join(absatz))}

") absatz.clear() def liste_schliessen() -> None: if liste: # `.extend()` und nicht `+=`: Das Zuweisen machte `ausgabe` zu # einer Ortsvariablen dieser inneren Funktion, und der Zugriff # schlüge fehl, bevor er etwas anhängen könnte. ausgabe.append("
    ") ausgabe.extend(f"
  • {fliesstext(punkt)}
  • " for punkt in liste) ausgabe.append("
") liste.clear() while index < len(zeilen): zeile = zeilen[index] blank = zeile.strip() == "" if zeile.startswith("```"): absatz_schliessen() liste_schliessen() index += 1 inhalt: list[str] = [] while index < len(zeilen) and not zeilen[index].startswith("```"): inhalt.append(zeilen[index]) index += 1 ausgabe.append(f"
{maskieren(chr(10).join(inhalt))}
") index += 1 continue if zeile.startswith("|"): absatz_schliessen() liste_schliessen() block: list[str] = [] while index < len(zeilen) and zeilen[index].startswith("|"): block.append(zeilen[index]) index += 1 ausgabe += tabelle(block) continue if blank: absatz_schliessen() liste_schliessen() elif zeile.startswith("### "): absatz_schliessen() liste_schliessen() text = fliesstext(zeile[4:]) ausgabe.append(f'

{text}

') elif zeile.startswith("## "): absatz_schliessen() liste_schliessen() text = fliesstext(zeile[3:]) ausgabe.append(f'

{text}

') elif zeile.startswith("# "): absatz_schliessen() liste_schliessen() ausgabe.append(f"

{fliesstext(zeile[2:])}

") elif zeile.strip() == "---": absatz_schliessen() liste_schliessen() # Die Trennlinien gliedern die Abschnitte. Als
wären sie eine # zweite, tonlose Gliederung neben den Überschriften; Bildschirm- # leser lesen sie mit. Die Überschriften tragen die Gliederung # bereits, deshalb entfallen sie. elif zeile.startswith("- "): absatz_schliessen() liste.append(zeile[2:].strip()) elif liste and zeile.startswith(" ") and not FORTSETZUNG_KEIN_UNTERPUNKT.match(zeile): liste[-1] += " " + zeile.strip() # umgebrochener Listenpunkt else: # Was der Umsetzer nicht kann, muss er melden – so sagt es der # Modulkopf zu. # # Bis Fassung 0.24.1 standen hier nur zwei Formen, und drei # weitere fielen still durch. Nachgemessen an `umsetzen()`: # „- Punkt\n - Unterpunkt" zog den Unterpunkt in den # Elterneintrag hinein und liess ihn als eigenen Punkt # verschwinden; „#### Vierte Ebene" erschien mit den vier Rauten # als Text und ohne Ebene in der Gliederung (Barriere nach # WCAG 1.3.1); „*kursiv*" zeigte die Sternchen. # # Die Seite ist die veroeffentlichte Datenschutzerklaerung – dort # ist eine stumm verschluckte Auszeichnung schlimmer als ein # Abbruch beim Erzeugen. for unerwartet, was in ( ("> ", "Blockzitat"), ("* ", "Aufzählung mit *"), ("#### ", "Überschrift der vierten Ebene"), ): if zeile.lstrip().startswith(unerwartet): sys.exit( f"Zeile {index + 1}: {was} gefunden. Diese Auszeichnung " "setzt der Umsetzer nicht um; sie würde stillschweigend " "als Absatz erscheinen. Entweder im Dokument vermeiden " "oder hier ergänzen." ) if UNTERPUNKT.match(zeile): sys.exit( f"Zeile {index + 1}: verschachtelte Aufzählung gefunden. " "Der Umsetzer kennt nur eine Ebene; der Unterpunkt würde " "in den Punkt darüber hineingezogen und verschwände. " "Entweder im Dokument vermeiden oder hier ergänzen." ) if KURSIV.search(zeile): sys.exit( f"Zeile {index + 1}: Kursivauszeichnung gefunden. Der " "Umsetzer kennt nur **fett**; die Sternchen erschienen als " "Zeichen auf der Seite. Entweder im Dokument vermeiden " "oder hier ergänzen." ) absatz.append(zeile.strip()) index += 1 absatz_schliessen() liste_schliessen() return "\n".join(ausgabe) STIL = """ :root { color-scheme: light dark; } body { max-width: 46rem; margin: 0 auto; padding: 2rem 1rem; font-family: system-ui, sans-serif; line-height: 1.6; } h1, h2, h3 { line-height: 1.25; } h2 { margin-top: 2.5rem; } table { border-collapse: collapse; width: 100%; margin: 1rem 0; } th, td { border: 1px solid; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; } pre { padding: 0.6rem 0.8rem; overflow-x: auto; border: 1px solid; } code { font-family: ui-monospace, monospace; } a { color: inherit; } """ def main() -> None: inhalt = umsetzen(veroeffentlichungsteil(QUELLE.read_text(encoding="utf-8"))) seite = ( "\n" '\n' "\n" '\n' '\n' f"{TITEL}\n" f"\n" "\n" "\n" "\n" "\n" f"{inhalt}\n" "\n" "\n" "\n" ) # `newline="\n"` ausdrücklich: Ohne die Angabe übersetzt Python unter # Windows jedes `\n` in ein `\r\n`. `.gitattributes` verlangt aber # `* text=auto eol=lf`, und der Arbeitsbaum soll dieselben Zeilenenden # tragen wie das Archiv – sonst meldet Prettier eine Datei, an der # inhaltlich nichts falsch ist. with ZIEL.open("w", encoding="utf-8", newline="\n") as datei: datei.write(seite) print(f"{ZIEL.relative_to(WURZEL)} geschrieben ({len(seite):,} Zeichen).") if __name__ == "__main__": main()