diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/engine.py b/app/engine.py
new file mode 100644
index 0000000..7954564
--- /dev/null
+++ b/app/engine.py
@@ -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")
diff --git a/app/static/table.css b/app/static/table.css
new file mode 100644
index 0000000..4460dff
--- /dev/null
+++ b/app/static/table.css
@@ -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; }
+}
diff --git a/app/templates/table.html b/app/templates/table.html
new file mode 100644
index 0000000..1bd1ada
--- /dev/null
+++ b/app/templates/table.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+{{ schema.title }}
+
+
+
+
+
+ {{ schema.title }}
+ {% if version_label %}Stand: {{ version_label }}
{% endif %}
+
+
+
+
+
+ {% for col in columns %}| {{ col.label }} | {% endfor %}
+
+
+
+ {% for row in rows %}
+
+ {% for cell in row %}
+ |
+ {%- if cell.type == "url" and cell.text -%}
+
+ {{ cell.qr | safe }}
+ {{ cell.short }}
+
+ {%- elif cell.type == "rating_5" and cell.stars -%}
+ {{ cell.stars }}
+ {%- else -%}{{ cell.text }}{%- endif -%}
+ |
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+
+
+
+
diff --git a/tests/test_engine.py b/tests/test_engine.py
new file mode 100644
index 0000000..81a110d
--- /dev/null
+++ b/tests/test_engine.py
@@ -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 "