Initiale Ablage der umsortierten Skrift-Programme

Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
"""Minimale Tests für die Kernlogik: Token-Suche, Geometrie, Fehlerfälle."""
import re
import fitz
import pytest
from core import (
PT_TO_MM,
PlacementError,
build_a4_svg,
find_token_occurrences,
parse_svg,
place_all,
signatur_geometrie,
)
from models import SignatureJob
# ── Test-Hilfen ─────────────────────────────────────────────────────────
SVG_OK = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40">'
b'<path d="M0 20 L100 20" stroke="black"/></svg>'
)
SVG_NO_VIEWBOX = (
b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40">'
b'<path d="M0 20 L100 20"/></svg>'
)
def make_pdf(tokens_at):
"""Erzeugt ein A4-PDF (1 Seite) mit den Token an gegebenen Punkt-Positionen."""
doc = fitz.open()
page = doc.new_page(width=595.0, height=842.0) # A4 in pt
for token, (x, y) in tokens_at:
page.insert_text((x, y), token, fontsize=11)
pdf_bytes = doc.tobytes()
doc.close()
return pdf_bytes
def _translate_from_svg(a4_svg):
"""Liest tx, ty aus dem ersten translate(...) des A4-SVG."""
m = re.search(r"translate\(([-\d.]+),([-\d.]+)\)", a4_svg)
assert m, "kein translate gefunden"
return float(m.group(1)), float(m.group(2))
def _scale_from_svg(a4_svg):
m = re.search(r"scale\(([-\d.]+)\)", a4_svg)
assert m, "kein scale gefunden"
return float(m.group(1))
# ── viewBox / SVG ───────────────────────────────────────────────────────
def test_parse_svg_liest_viewbox():
vb = parse_svg(SVG_OK)
assert (vb.minx, vb.miny, vb.vw, vb.vh) == (0.0, 0.0, 100.0, 40.0)
assert "<path" in vb.inner
def test_parse_svg_ohne_viewbox_fehler():
with pytest.raises(PlacementError):
parse_svg(SVG_NO_VIEWBOX)
# ── Geometrie ───────────────────────────────────────────────────────────
def test_geometrie_unter():
vb = parse_svg(SVG_OK)
# Token bei y0 = 72 pt = 25.4 mm
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="unter", breite_mm=45.0, abstand_mm=8.0
)
assert skala == pytest.approx(45.0 / 100.0)
assert h == pytest.approx(40.0 * 0.45) # 18 mm
assert x_mm == pytest.approx(72.0 * PT_TO_MM) # 25.4 mm
# unter: y_token + abstand
assert y_sig == pytest.approx(72.0 * PT_TO_MM + 8.0) # 33.4 mm
def test_geometrie_genau():
vb = parse_svg(SVG_OK)
# "genau": Signatur sitzt exakt an der Marker-Position (kein Abstand).
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="genau", breite_mm=45.0, abstand_mm=8.0
)
assert y_sig == pytest.approx(72.0 * PT_TO_MM) # == y_token, abstand ignoriert
assert x_mm == pytest.approx(72.0 * PT_TO_MM)
def test_position_default_ist_genau():
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg")
assert job.position == "genau"
def test_geometrie_ueber():
vb = parse_svg(SVG_OK)
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="ueber", breite_mm=45.0, abstand_mm=8.0
)
# ueber: y_token - abstand - hoehe
expected = 72.0 * PT_TO_MM - 8.0 - h
assert y_sig == pytest.approx(expected)
# über muss deutlich höher (kleineres y) liegen als unter
assert y_sig < 72.0 * PT_TO_MM
def test_geometrie_viewbox_offset_in_translate():
"""Ein viewBox-Offset muss in die Translation eingerechnet werden."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 5 100 40">'
b'<path d="M10 25 L110 25"/></svg>'
)
vb = parse_svg(svg)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")
a4 = build_a4_svg(vb, rect, job)
tx, ty = _translate_from_svg(a4)
skala = _scale_from_svg(a4)
# tx = x_sig - minx*skala
assert tx == pytest.approx(72.0 * PT_TO_MM - 10.0 * skala, abs=1e-3)
def test_versatz_verschiebt_links_rechts_oben_unten():
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
base = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau")
moved = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
versatz_x_mm=5.0, versatz_y_mm=-3.0)
a4_base = build_a4_svg(vb, rect, base)
a4_moved = build_a4_svg(vb, rect, moved)
bx, by = _translate_from_svg(a4_base)
mx, my = _translate_from_svg(a4_moved)
assert mx == pytest.approx(bx + 5.0, abs=1e-3) # +x → nach rechts
assert my == pytest.approx(by - 3.0, abs=1e-3) # −y → nach oben
def test_immer_single_stroke_fill_none_und_stroke():
"""Signaturen werden immer als Single-Stroke gezeichnet (fill:none + stroke)."""
vb = parse_svg(SVG_OK)
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
job = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau",
breite_mm=50.0, linienstaerke_mm=0.5)
a4 = build_a4_svg(vb, rect, job)
assert 'fill="none"' in a4
assert "stroke=" in a4 and "stroke-width=" in a4
# stroke-width in viewBox-Einheiten: 0.5mm / skala (skala = 50/100 = 0.5) = 1.0
import re
sw = float(re.search(r'stroke-width="([\d.]+)"', a4).group(1))
assert sw == pytest.approx(1.0, abs=1e-3)
def test_root_transform_wird_nicht_uebernommen():
"""Ein transform auf der äußersten <svg> darf die Platzierung nicht verfälschen."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40" '
b'transform="rotate(45)" fill="blue"><path d="M0 20 L100 20"/></svg>'
)
vb = parse_svg(svg)
assert "transform" not in vb.group_attrs # nicht mitgeführt
assert vb.group_attrs.get("fill") == "blue" # echtes Styling bleibt
rect = fitz.Rect(72.0, 72.0, 120.0, 84.0)
a4 = build_a4_svg(vb, rect, SignatureJob(token="§§S§§", svg_id="s.svg"))
# Genau ein transform (die Positionierung), nicht zwei.
assert a4.count("transform=") == 1
# ── Token-Suche ─────────────────────────────────────────────────────────
def test_find_token_einmal():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 1
page_index, rect = occ[0]
assert page_index == 0
assert rect.x0 == pytest.approx(100.0, abs=2.0) # nahe Einfügepunkt
def test_find_token_nicht_gefunden():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
assert find_token_occurrences(doc, "§§SIG9§§") == []
doc.close()
def test_find_token_mehrfach_erlaubt():
# Derselbe Token mehrfach (z.B. Unterschrift auf mehreren Stellen) → alle Treffer.
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0)), ("§§SIG1§§", (100.0, 400.0))])
doc = fitz.open(stream=pdf, filetype="pdf")
occ = find_token_occurrences(doc, "§§SIG1§§")
doc.close()
assert len(occ) == 2
# ── place_all: Gesamtfluss + Fehlerliste ────────────────────────────────
def make_multipage_pdf(per_page_tokens):
"""Erzeugt ein A4-PDF mit mehreren Seiten; pro Seite eine Liste (token, (x,y))."""
doc = fitz.open()
for tokens in per_page_tokens:
page = doc.new_page(width=595.0, height=842.0)
for token, (x, y) in tokens:
page.insert_text((x, y), token, fontsize=11)
data = doc.tobytes()
doc.close()
return data
def test_place_all_erfolg():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="unterschrift.svg", position="unter")]
pages, errors = place_all(pdf, {"unterschrift.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 1
p = pages[0]
assert p.filename == "seite_1.svg"
assert p.tokens == ["§§SIG1§§"]
assert p.a4_svg.startswith("<svg")
assert 'viewBox="0 0 210 297"' in p.a4_svg
def test_place_all_token_auf_mehreren_seiten():
"""Derselbe Token auf mehreren Seiten → pro Seite eine A4-SVG."""
pdf = make_multipage_pdf([
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
])
jobs = [SignatureJob(token="[[sign]]", svg_id="u.svg", position="genau")]
pages, errors = place_all(pdf, {"u.svg": SVG_OK}, jobs)
assert errors == []
assert [p.filename for p in pages] == ["seite_1.svg", "seite_2.svg", "seite_3.svg"]
def test_place_all_zwei_tokens_pro_seite_kombiniert():
"""Zwei verschiedene Token auf jeder Seite → eine A4-SVG pro Seite mit beiden."""
pdf = make_multipage_pdf([
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
])
jobs = [
SignatureJob(token="[[fk]]", svg_id="a.svg", position="genau"),
SignatureJob(token="[[tv]]", svg_id="b.svg", position="genau"),
]
pages, errors = place_all(pdf, {"a.svg": SVG_OK, "b.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 2
# Jede Seiten-SVG enthält beide Signaturen (zwei positionierte Gruppen).
for p in pages:
assert sorted(p.tokens) == ["[[fk]]", "[[tv]]"]
assert p.a4_svg.count("translate(") == 2
def test_place_all_svg_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter")]
placements, errors = place_all(pdf, {}, jobs)
assert placements == []
assert any("nicht hochgeladen" in e for e in errors)
def test_place_all_token_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG2§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert any("nicht im PDF" in e for e in errors)
def test_place_all_sammelt_mehrere_fehler():
"""Mehrere fehlerhafte Jobs → alle Fehler werden gemeldet, nicht nur der erste."""
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [
SignatureJob(token="§§SIGX§§", svg_id="s.svg", position="unter"), # Token fehlt
SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter"), # SVG fehlt
]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert len(errors) == 2
def test_place_all_svg_ohne_viewbox():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_NO_VIEWBOX}, jobs)
assert placements == []
assert any("viewBox" in e for e in errors)