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,315 @@
"""
Kernlogik des Signatur-Plotter-Service.
Hier liegt die gemeinsame Logik, die /render und /preview beide nutzen:
1. Marker-Token im PDF finden (eindeutig) und Koordinaten auslesen.
2. Die zugeordnete SVG geometrisch korrekt auf einer A4-SVG platzieren.
Der Service ist zustandslos: alles arbeitet auf den übergebenen Bytes.
"""
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import List, Tuple
import fitz # PyMuPDF
from models import SignatureJob
# ── Konstanten ──────────────────────────────────────────────────────────
PT_TO_MM = 25.4 / 72.0 # 1 pt = 25.4/72 mm
A4_WIDTH_MM = 210.0
A4_HEIGHT_MM = 297.0
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
# Saubere Namensräume beim Serialisieren (kein ns0:-Präfix im Output)
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
# Strukturelle Attribute der Wurzel-<svg>, die NICHT auf die Gruppe übernommen werden
_SKIP_ROOT_ATTRS = {
"viewbox", "width", "height", "xmlns", "x", "y", "id",
"version", "preserveaspectratio", "baseprofile",
# transform auf der äußersten <svg> wird laut Spec ignoriert – darf daher NICHT
# auf die Gruppe übernommen werden, sonst verzieht/verschiebt es die Signatur.
"transform",
}
class PlacementError(Exception):
"""Eingabe-/Validierungsfehler, der dem Aufrufer als Fehlermeldung gemeldet wird."""
@dataclass
class ViewBox:
"""Aus der SVG gelesene viewBox plus eingebetteter Inhalt."""
minx: float
miny: float
vw: float
vh: float
inner: str # serialisierte Kind-Elemente der <svg>
group_attrs: dict = field(default_factory=dict) # übernommene Styling-Attribute
@dataclass
class PageRender:
"""Ergebnis pro betroffener Seite: eine A4-SVG mit allen Signaturen dieser Seite."""
page_index: int # 0-basiert
a4_svg: str # fertige A4-SVG als String
filename: str # seite_<n>.svg
tokens: list = field(default_factory=list) # welche Token auf der Seite platziert wurden
# ── SVG einlesen ────────────────────────────────────────────────────────
def parse_svg(svg_bytes: bytes) -> ViewBox:
"""Liest viewBox und Inhalt einer SVG. Wirft PlacementError bei fehlender viewBox."""
try:
root = ET.fromstring(svg_bytes)
except ET.ParseError as e:
raise PlacementError(f"SVG nicht lesbar ({e}).")
# viewBox case-insensitiv suchen (XML ist eigentlich case-sensitiv, aber wir sind tolerant)
vb_raw = root.get("viewBox") or root.get("viewbox")
if not vb_raw:
raise PlacementError("SVG ohne viewBox – Skalierung nicht möglich.")
parts = re.split(r"[\s,]+", vb_raw.strip())
if len(parts) != 4:
raise PlacementError("viewBox hat nicht genau 4 Werte.")
try:
minx, miny, vw, vh = (float(p) for p in parts)
except ValueError:
raise PlacementError("viewBox enthält ungültige Zahlen.")
if vw <= 0 or vh <= 0:
raise PlacementError("viewBox-Breite/Höhe muss > 0 sein.")
# Inhalt der <svg> (alle Kinder) als String – Wrapper-<svg> entfällt, wir nutzen <g>.
inner = "".join(ET.tostring(child, encoding="unicode") for child in root)
# Styling-Attribute der Wurzel (z.B. fill/stroke) auf die spätere Gruppe übertragen,
# damit als Präsentationsattribut gesetzte Standardfarben nicht verloren gehen.
group_attrs = {}
for k, v in root.attrib.items():
local = k.split("}")[-1].lower()
if local not in _SKIP_ROOT_ATTRS:
group_attrs[k.split("}")[-1]] = v
return ViewBox(minx, miny, vw, vh, inner, group_attrs)
# ── Token-Suche ─────────────────────────────────────────────────────────
def find_token_occurrences(doc: fitz.Document, token: str) -> List[Tuple[int, fitz.Rect]]:
"""
Sucht den Token im gesamten PDF und liefert ALLE Vorkommen.
Der Token darf mehrfach vorkommen (z.B. dieselbe Unterschrift auf jeder Seite) –
jedes Vorkommen wird platziert. Rückgabe: Liste von (page_index, rect), evtl. leer.
"""
hits: List[Tuple[int, fitz.Rect]] = []
for page_index in range(doc.page_count):
for rect in doc[page_index].search_for(token):
hits.append((page_index, rect))
return hits
# ── Geometrie ───────────────────────────────────────────────────────────
def signatur_geometrie(x0_pt: float, y0_pt: float, vb: ViewBox,
position: str, breite_mm: float, abstand_mm: float,
versatz_x_mm: float = 0.0, versatz_y_mm: float = 0.0):
"""
Berechnet Platzierung der Signatur in mm.
Referenzpunkt ist die linke Oberkante des Tokens (rect.x0, rect.y0).
PyMuPDF: Ursprung oben links, Y wächst nach unten.
versatz_x_mm/versatz_y_mm verschieben das Ergebnis zusätzlich
(+x = rechts, +y = unten) zur Feinjustierung.
Rückgabe: (x_sig_mm, y_sig_mm, skala, signatur_hoehe_mm)
"""
# Token-Position von Punkten in mm
x_token_mm = x0_pt * PT_TO_MM
y_token_mm = y0_pt * PT_TO_MM
# Gleichmäßige Skalierung anhand der viewBox-Breite (nicht width/height!)
skala = breite_mm / vb.vw
signatur_hoehe_mm = vb.vh * skala
if position == "ueber":
y_sig = y_token_mm - abstand_mm - signatur_hoehe_mm
elif position == "unter":
y_sig = y_token_mm + abstand_mm
else: # "genau" – Signatur sitzt direkt an der Marker-Position (kein Abstand)
y_sig = y_token_mm
# Feinjustierung links/rechts/oben/unten
x_sig = x_token_mm + versatz_x_mm
y_sig = y_sig + versatz_y_mm
return x_sig, y_sig, skala, signatur_hoehe_mm
def build_signature_group(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
"""Erzeugt die positionierte <g>-Gruppe einer einzelnen Signatur (ohne A4-Wrapper)."""
x_sig, y_sig, skala, _ = signatur_geometrie(
rect.x0, rect.y0, vb, job.position, job.breite_mm, job.abstand_mm,
job.versatz_x_mm, job.versatz_y_mm,
)
# viewBox-Offset (minx/miny) in die Translation einrechnen, damit die linke
# Oberkante des Signatur-Inhalts exakt auf (x_sig, y_sig) liegt.
tx = x_sig - vb.minx * skala
ty = y_sig - vb.miny * skala
# Positionierung und Signatur-eigene Attribute getrennt halten:
# äußere Gruppe = nur translate/scale, innere Gruppe = Styling der Original-<svg>
# (fill/stroke …). So kollidiert nie ein mitgeführtes Attribut mit dem transform.
attrs = dict(vb.group_attrs)
# Signaturen werden immer als Single-Stroke gezeichnet: offene Kurven als Linie
# statt gefüllt. stroke-width in viewBox-Einheiten, damit nach scale(skala) die
# gewünschte mm-Stärke herauskommt. Greift auf Pfade ohne eigene fill/stroke-Angabe.
sw = job.linienstaerke_mm / skala if skala else job.linienstaerke_mm
stroke = attrs.get("stroke")
if not stroke or stroke == "none":
stroke = "#000000"
attrs.update({
"fill": "none",
"stroke": stroke,
"stroke-width": f"{sw:.4f}",
"stroke-linecap": "round",
"stroke-linejoin": "round",
})
attr = "".join(f' {k}="{_xml_escape(str(v))}"' for k, v in attrs.items())
inner_group = f"<g{attr}>{vb.inner}</g>" if attr else vb.inner
return (
f'<g transform="translate({tx:.4f},{ty:.4f}) scale({skala:.6f})">'
f"{inner_group}</g>"
)
def wrap_a4(groups: List[str]) -> str:
"""Verpackt Signatur-Gruppen in eine A4-Plot-SVG (nur Signaturen, Rest transparent)."""
return (
f'<svg xmlns="{SVG_NS}" xmlns:xlink="{XLINK_NS}" '
f'width="{A4_WIDTH_MM}mm" height="{A4_HEIGHT_MM}mm" '
f'viewBox="0 0 {A4_WIDTH_MM:g} {A4_HEIGHT_MM:g}">'
f"{''.join(groups)}</svg>"
)
def build_a4_svg(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
"""Bequemlichkeit: A4-SVG mit genau einer Signatur (v.a. für Tests)."""
return wrap_a4([build_signature_group(vb, rect, job)])
def _xml_escape(v: str) -> str:
return (v.replace("&", "&amp;").replace('"', "&quot;")
.replace("<", "&lt;").replace(">", "&gt;"))
# ── Hauptlogik: alle Jobs platzieren ────────────────────────────────────
def place_all(pdf_bytes: bytes, svgs: dict, jobs: List[SignatureJob]):
"""
Platziert alle Jobs. Sammelt ALLE Fehler (kein stilles Überspringen).
Ein Token darf MEHRFACH vorkommen (Unterschrift auf mehreren Seiten) – jedes
Vorkommen wird platziert. Pro betroffener Seite entsteht eine A4-SVG, die alle
Signaturen dieser Seite enthält. Nur 0 Treffer ist ein Fehler.
Rückgabe: (pages, errors)
pages: List[PageRender] – je betroffene Seite eine A4-SVG
errors: List[str] – leer, wenn alles geklappt hat
"""
errors: List[str] = []
# Pro Seite die platzierten Signatur-Gruppen sammeln: page_index -> [(token, group)]
page_items: dict = {}
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as e:
return [], [f"PDF konnte nicht geöffnet werden: {e}"]
try:
for job in jobs:
# 1. Ist die zugeordnete SVG hochgeladen?
if job.svg_id not in svgs:
errors.append(
f"Token '{job.token}': SVG '{job.svg_id}' wurde nicht hochgeladen."
)
continue
# 2. viewBox lesbar?
try:
vb = parse_svg(svgs[job.svg_id])
except PlacementError as e:
errors.append(f"Token '{job.token}': {e}")
continue
# 3. Token im PDF vorhanden? (Mehrfachtreffer sind ausdrücklich erlaubt.)
occurrences = find_token_occurrences(doc, job.token)
if not occurrences:
errors.append(f"Token '{job.token}' nicht im PDF gefunden.")
continue
for page_index, rect in occurrences:
group = build_signature_group(vb, rect, job)
# rect.x0 (x-Position des Tokens) mitführen, um später links→rechts zu ordnen
page_items.setdefault(page_index, []).append((rect.x0, job.token, group))
finally:
doc.close()
if errors:
return [], errors
pages: List[PageRender] = []
for page_index in sorted(page_items):
# Signaturen einer Seite links→rechts sortieren: der Plotter schreibt in
# dieser Richtung, also linke Signatur immer vor der rechten abarbeiten.
items = sorted(page_items[page_index], key=lambda t: t[0])
a4 = wrap_a4([group for _, _, group in items])
pages.append(PageRender(
page_index=page_index,
a4_svg=a4,
filename=f"seite_{page_index + 1}.svg",
tokens=[token for _, token, _ in items],
))
return pages, errors
# ── Vorschau (vektoriell zusammengeführtes PDF) ─────────────────────────
def build_preview_pdf(pdf_bytes: bytes, pages: List[PageRender]) -> bytes:
"""
Baut ein Vorschau-PDF mit allen betroffenen Seiten und den Signaturen als Overlay.
Gewählter Weg: Die fertige A4-Signatur-SVG (pro Seite) wird via PyMuPDF nach PDF
konvertiert und mit show_pdf_page() deckungsgleich über eine Kopie der
Originalseite gelegt. Begründung: bleibt voll vektoriell (keine Rasterungs-
Artefakte), nutzt exakt dieselbe Geometrie wie /render und kommt ohne zusätzliche
Pillow-Abhängigkeit aus. Es wird vorausgesetzt, dass die PDF-Seiten A4 sind.
"""
src = fitz.open(stream=pdf_bytes, filetype="pdf")
out = fitz.open()
try:
for p in sorted(pages, key=lambda x: x.page_index):
# Originalseite 1:1 übernehmen …
out.insert_pdf(src, from_page=p.page_index, to_page=p.page_index)
page = out[-1]
# … und die A4-Signatur-SVG dieser Seite darüberlegen.
sig_svg = fitz.open(stream=p.a4_svg.encode("utf-8"), filetype="svg")
try:
sig_pdf = fitz.open("pdf", sig_svg.convert_to_pdf())
page.show_pdf_page(page.rect, sig_pdf, 0)
sig_pdf.close()
finally:
sig_svg.close()
data = out.tobytes()
finally:
out.close()
src.close()
return data