Kern-Engine: CSV parsen/validieren, QR-SVGs, HTML-Rendering + Selbsttest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 11:00:40 +02:00
parent 3d7787a3a4
commit e9b59fe4c8
5 changed files with 444 additions and 0 deletions

0
app/__init__.py Normal file
View File

187
app/engine.py Normal file
View File

@@ -0,0 +1,187 @@
"""Kern-Engine: Schema + CSV -> validierte Daten -> gerenderte HTML-Seite.
Bewusst framework-frei gehalten, damit sie ohne Flask testbar bleibt.
"""
from __future__ import annotations
import csv
import io
import re
from datetime import datetime
from pathlib import Path
import segno
from jinja2 import Environment, FileSystemLoader, select_autoescape
COLUMN_TYPES = ("text", "text_long", "number", "date", "url", "rating_5")
TYPE_LABELS = {
"text": "Text (kurz)",
"text_long": "Text (lang)",
"number": "Zahl",
"date": "Datum",
"url": "Link (mit QR-Code)",
"rating_5": "Bewertung (1-5 Sterne)",
}
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
)
def slugify(text: str) -> str:
"""Titel -> URL-tauglicher Slug (Umlaute transliteriert)."""
text = text.strip().lower()
for src, dst in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
text = text.replace(src, dst)
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def column_key(label: str) -> str:
return slugify(label).replace("-", "_") or "spalte"
def _norm_label(label: str) -> str:
return " ".join(label.strip().lower().split())
def _decode(raw: bytes) -> str:
# Excel (Windows) liefert oft cp1252, "CSV UTF-8" liefert BOM.
for encoding in ("utf-8-sig", "cp1252"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
def _sniff_delimiter(text: str) -> str:
first_line = text.splitlines()[0] if text.splitlines() else ""
counts = {d: first_line.count(d) for d in (";", ",", "\t")}
best = max(counts, key=counts.get) # deutsches Excel: Semikolon
return best if counts[best] > 0 else ";"
def parse_csv(raw: bytes) -> tuple[list[str], list[list[str]]]:
"""Rohbytes -> (Kopfzeile, Datenzeilen). Leerzeilen werden verworfen."""
text = _decode(raw)
reader = csv.reader(io.StringIO(text), delimiter=_sniff_delimiter(text))
rows = [row for row in reader if any(cell.strip() for cell in row)]
if not rows:
return [], []
return [cell.strip() for cell in rows[0]], rows[1:]
def _check_cell(col: dict, value: str) -> str | None:
if not value:
return None # leere Zellen sind erlaubt
if col["type"] == "url" and not re.fullmatch(r"https?://\S+", value):
return "keine gueltige URL (muss mit http:// oder https:// beginnen)"
if col["type"] == "rating_5" and (not value.isdigit() or not 1 <= int(value) <= 5):
return "Bewertung muss eine ganze Zahl von 1 bis 5 sein"
if col["type"] == "number":
try:
float(value.replace(",", "."))
except ValueError:
return "keine Zahl"
return None
def validate_and_map(schema: dict, header: list[str], rows: list[list[str]]):
"""CSV-Inhalt gegen das Schema pruefen.
Rueckgabe: (records, errors, warnings) — records ist eine Liste
von Dicts key -> Zellwert, nur gefuellt wenn errors leer ist.
"""
errors: list[str] = []
warnings: list[str] = []
norm_header = [_norm_label(h) for h in header]
col_index: dict[str, int] = {}
for col in schema["columns"]:
try:
col_index[col["key"]] = norm_header.index(_norm_label(col["label"]))
except ValueError:
errors.append(f'Spalte "{col["label"]}" fehlt in der CSV.')
known = {_norm_label(c["label"]) for c in schema["columns"]}
for h in header:
if _norm_label(h) not in known:
warnings.append(f'Unbekannte Spalte "{h}" wird ignoriert.')
if errors:
return [], errors, warnings
records: list[dict] = []
for lineno, row in enumerate(rows, start=2):
record = {}
for col in schema["columns"]:
idx = col_index[col["key"]]
value = row[idx].strip() if idx < len(row) else ""
problem = _check_cell(col, value)
if problem:
errors.append(f'Zeile {lineno}, Spalte "{col["label"]}": {problem}')
record[col["key"]] = value
records.append(record)
if errors:
return [], errors, warnings
return records, errors, warnings
def qr_svg(data: str) -> str:
"""QR-Code als Inline-SVG (skaliert per CSS, kein width/height-Attribut)."""
buf = io.BytesIO()
segno.make(data, error="m").save(
buf, kind="svg", xmldecl=False, omitsize=True, border=1, dark="#26221c"
)
return buf.getvalue().decode("utf-8")
def _shorten_url(url: str, max_len: int = 42) -> str:
display = re.sub(r"^https?://(www\.)?", "", url).rstrip("/")
return display if len(display) <= max_len else display[: max_len - 1] + ""
def _build_cells(schema: dict, records: list[dict]) -> list[list[dict]]:
rows = []
for record in records:
cells = []
for col in schema["columns"]:
value = record[col["key"]]
cell = {"type": col["type"], "text": value}
if col["type"] == "url" and value:
cell["qr"] = qr_svg(value)
cell["short"] = _shorten_url(value)
if col["type"] == "rating_5" and value:
n = int(value)
cell["stars"] = "" * n + "" * (5 - n)
cells.append(cell)
rows.append(cells)
return rows
def render_page(schema: dict, records: list[dict], version_label: str = "") -> str:
"""Fertige, in sich geschlossene HTML-Seite (CSS eingebettet)."""
css = (STATIC_DIR / "table.css").read_text(encoding="utf-8")
return _env.get_template("table.html").render(
schema=schema,
columns=schema["columns"],
rows=_build_cells(schema, records),
css=css,
version_label=version_label,
generated=datetime.now().strftime("%d.%m.%Y %H:%M"),
)
def template_csv(schema: dict) -> bytes:
"""Leere CSV-Vorlage: Semikolon + UTF-8-BOM, damit Excel sie direkt oeffnet."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";", lineterminator="\r\n")
writer.writerow([col["label"] for col in schema["columns"]])
return buf.getvalue().encode("utf-8-sig")

117
app/static/table.css Normal file
View File

@@ -0,0 +1,117 @@
/* Design-Tokens — zentrale Stellschrauben, Feintuning spaeter.
Fonts: Systemstack als Fallback; Source Serif 4 / Fira Sans bei Bedarf
lokal hosten (DSGVO/Schulkontext — kein Google-CDN). */
:root {
--font-heading: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-body: "Fira Sans", "Segoe UI", system-ui, sans-serif;
--color-paper: #fbfaf7;
--color-ink: #26221c;
--color-muted: #6f6a60;
--color-accent: #1f5e79;
--color-stripe: #f2efe8;
--color-border: #ddd8cc;
--color-stars: #c9931f;
--qr-size: 96px;
--content-max: 1100px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-body);
line-height: 1.45;
}
main {
max-width: var(--content-max);
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
}
.page-head h1 {
font-family: var(--font-heading);
font-size: clamp(1.5rem, 4vw, 2.2rem);
margin: 0.5rem 0 0.2rem;
}
.meta {
color: var(--color-muted);
font-size: 0.85rem;
margin: 0 0 1rem;
}
.table-wrap { overflow-x: auto; }
table {
border-collapse: collapse;
width: 100%;
background: #fff;
border: 1px solid var(--color-border);
}
th {
font-family: var(--font-heading);
text-align: left;
font-size: 0.95rem;
background: var(--color-ink);
color: var(--color-paper);
position: sticky;
top: 0;
}
th, td {
padding: 0.6rem 0.75rem;
vertical-align: top;
border-bottom: 1px solid var(--color-border);
}
tbody tr:nth-child(even) { background: var(--color-stripe); }
.t-number { text-align: right; white-space: nowrap; width: 1%; }
.t-date { white-space: nowrap; }
.t-text_long { white-space: pre-line; max-width: 46ch; }
td.t-url { text-align: center; width: calc(var(--qr-size) + 1.5rem); }
.qr-cell {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
text-decoration: none;
}
.qr { display: block; }
.qr svg { width: var(--qr-size); height: var(--qr-size); display: block; }
.qr-link {
font-size: 0.72rem;
color: var(--color-accent);
word-break: break-all;
max-width: calc(var(--qr-size) + 2rem);
}
.stars {
color: var(--color-stars);
font-size: 1.1rem;
letter-spacing: 0.1em;
white-space: nowrap;
}
.page-foot {
margin-top: 1.5rem;
color: var(--color-muted);
font-size: 0.78rem;
}
@media (max-width: 700px) {
th, td { padding: 0.45rem 0.5rem; font-size: 0.9rem; }
}
@media print {
body { background: #fff; }
th { background: #fff; color: var(--color-ink); border-bottom: 2px solid var(--color-ink); }
.page-foot { display: none; }
}

45
app/templates/table.html Normal file
View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ schema.title }}</title>
<style>{{ css }}</style>
</head>
<body>
<main>
<header class="page-head">
<h1>{{ schema.title }}</h1>
{% if version_label %}<p class="meta">Stand: {{ version_label }}</p>{% endif %}
</header>
<div class="table-wrap">
<table>
<thead>
<tr>
{% for col in columns %}<th class="t-{{ col.type }}">{{ col.label }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for cell in row %}
<td class="t-{{ cell.type }}">
{%- if cell.type == "url" and cell.text -%}
<a href="{{ cell.text }}" class="qr-cell">
<span class="qr">{{ cell.qr | safe }}</span>
<span class="qr-link">{{ cell.short }}</span>
</a>
{%- elif cell.type == "rating_5" and cell.stars -%}
<span class="stars" aria-label="{{ cell.text }} von 5 Sternen">{{ cell.stars }}</span>
{%- else -%}{{ cell.text }}{%- endif -%}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<footer class="page-foot">Erzeugt am {{ generated }} · QR-Tabellen</footer>
</main>
</body>
</html>

95
tests/test_engine.py Normal file
View File

@@ -0,0 +1,95 @@
"""Schneller Selbsttest der Kern-Engine (ohne Framework, ohne pytest).
Aufruf: python tests/test_engine.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app import engine
SCHEMA = {
"slug": "test-stationen",
"title": "Test-Stationen",
"columns": [
{"key": "nr", "label": "Nr.", "type": "number"},
{"key": "titel", "label": "Titel", "type": "text"},
{"key": "link", "label": "Link", "type": "url"},
{"key": "bewertung", "label": "Bewertung", "type": "rating_5"},
],
}
def test_slugify():
assert engine.slugify("Projekttage Straßenfotografie 2026!") == "projekttage-strassenfotografie-2026"
assert engine.column_key("Nr.") == "nr"
def test_parse_semikolon_bom():
raw = "Nr.;Titel;Link;Bewertung\r\n1;Kamera hält;https://example.org/a;4\r\n".encode("utf-8-sig")
header, rows = engine.parse_csv(raw)
assert header == ["Nr.", "Titel", "Link", "Bewertung"]
assert rows == [["1", "Kamera hält", "https://example.org/a", "4"]]
def test_parse_cp1252_komma():
raw = "Nr.,Titel,Link,Bewertung\n2,Straße üben,https://example.org/b,5\n".encode("cp1252")
header, rows = engine.parse_csv(raw)
assert rows[0][1] == "Straße üben"
def test_validate_ok_und_fehler():
header = ["Nr.", "Titel", "Link", "Bewertung"]
records, errors, warnings = engine.validate_and_map(
SCHEMA, header, [["1", "A", "https://example.org", "3"], ["2", "B", "", ""]]
)
assert not errors and len(records) == 2
_, errors, _ = engine.validate_and_map(
SCHEMA, header, [["x", "A", "example.org", "7"]]
)
assert len(errors) == 3 # Zahl, URL, Bewertung
_, errors, _ = engine.validate_and_map(SCHEMA, ["Nr.", "Titel"], [])
assert any("fehlt" in e for e in errors)
def test_warnung_unbekannte_spalte():
header = ["Nr.", "Titel", "Link", "Bewertung", "Notizen"]
_, errors, warnings = engine.validate_and_map(SCHEMA, header, [["1", "A", "", ""]])
assert not errors
assert any("Notizen" in w for w in warnings)
def test_render():
records, errors, _ = engine.validate_and_map(
SCHEMA,
["Nr.", "Titel", "Link", "Bewertung"],
[["1", "Goldener Schnitt", "https://youtu.be/abc123", "4"]],
)
assert not errors
html = engine.render_page(SCHEMA, records, version_label="2026-07-05 12:00")
assert "<svg" in html # QR-Code eingebettet
assert "Test-Stationen" in html
assert "★★★★☆" in html
assert "https://youtu.be/abc123" in html
def test_template_csv():
raw = engine.template_csv(SCHEMA)
assert raw.startswith("".encode("utf-8")) # BOM fuer Excel
assert b"Nr.;Titel;Link;Bewertung" in raw
if __name__ == "__main__":
failed = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"OK {name}")
except AssertionError as exc:
failed += 1
print(f"FAIL {name}: {exc}")
sys.exit(1 if failed else 0)