gwoe-antragspruefer/app/pm_render.py
Dotty Dotter 9498ca4b97 refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block,
decisive-Outcome-Selection) in app/marker.py extrahiert. Template
ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik
testbar ohne Render-Kontext.

Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown-
Renderers in aktuelle-themen.html — fuer Tests und potenzielle
Server-side-Render-Optionen (z.B. PM-Mail).

Tests:
- tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome,
  consistency_state inkl. Multi-Vote, ambivalente Empfehlung,
  Edge-Cases.
- tests/test_pm_render.py (21 Cases): Bold, Italic, Listen,
  HTML-Escape, Paragraph-Splitting, snake_case-Schutz.

Refs: ADR 0010, ADR 0011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:44:12 +02:00

82 lines
2.8 KiB
Python

"""Mini-Markdown-Renderer für PM-Bodies — Python-Spiegelbild der
JS-Funktion ``renderPmBody`` in ``v2/screens/aktuelle-themen.html``.
Der Renderer interpretiert eine kleine Untermenge von Markdown:
- ``**bold**`` und ``__bold__`` → ``<strong>...</strong>``
- ``*italic*`` und ``_italic_`` → ``<em>...</em>`` (vorsichtig — nur
wenn nicht zwischen Ziffern oder Word-Charakters)
- Zeilen, die mit ``- `` oder ``* `` beginnen → ``<ul><li>...</li></ul>``
- Doppel-Newlines trennen Absätze → ``<p>...</p>``
- Einzelne Newlines innerhalb eines Absatzes → ``<br>``
- HTML-Special-Chars (``&``, ``<``, ``>``) werden escaped.
Die Python-Variante existiert primär zum Testen. Im Produktiv-Frontend
wird die JS-Variante benutzt (kein Round-Trip zum Server).
"""
from __future__ import annotations
import re
# 1) HTML-Escape (analog zur JS-Variante, in dieser Reihenfolge)
def _html_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 2) Bold-Marker: **...** und __...__
_RE_BOLD_STAR = re.compile(r"\*\*([^*\n]+?)\*\*")
_RE_BOLD_UNDER = re.compile(r"__([^_\n]+?)__")
# 3) Italic-Marker: *…* und _…_, jeweils nicht zw. Word-Chars
_RE_ITALIC_STAR = re.compile(r"(?<![*\w])\*([^*\n]+?)\*(?![*\w])")
_RE_ITALIC_UNDER = re.compile(r"(?<![_\w])_([^_\n]+?)_(?![_\w])")
# 4) Listen-Bullet
_RE_BULLET = re.compile(r"^\s*[-*]\s+")
def render_pm_body(body: str) -> str:
"""Render Mini-Markdown zu HTML. Leere/None-Eingabe → leerer String."""
if not body:
return ""
s = _html_escape(body)
s = _RE_BOLD_STAR.sub(r"<strong>\1</strong>", s)
s = _RE_BOLD_UNDER.sub(r"<strong>\1</strong>", s)
s = _RE_ITALIC_STAR.sub(r"<em>\1</em>", s)
s = _RE_ITALIC_UNDER.sub(r"<em>\1</em>", s)
# Listen: konsekutive "- "/"* "-Zeilen zu einem <ul> zusammenfassen
out_lines = []
in_list = False
for line in s.split("\n"):
if _RE_BULLET.match(line):
if not in_list:
out_lines.append('<ul style="margin:0.5em 0;padding-left:1.4em;">')
in_list = True
out_lines.append("<li>" + _RE_BULLET.sub("", line) + "</li>")
else:
if in_list:
out_lines.append("</ul>")
in_list = False
out_lines.append(line)
if in_list:
out_lines.append("</ul>")
s = "\n".join(out_lines)
# Paragraphen-Split bei doppelten Newlines, einzelne → <br>
paras = re.split(r"\n\s*\n", s)
rendered = []
for p in paras:
trimmed = p.strip()
if not trimmed:
continue
if trimmed.startswith("<"):
rendered.append(trimmed)
else:
rendered.append(
'<p style="margin:0 0 0.9em;">'
+ trimmed.replace("\n", "<br>")
+ "</p>"
)
return "\n".join(rendered)