Files
skrift-programme/Docker/signature-service/core.py
Lucas Orth 0341c4eaea 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>
2026-07-20 12:21:08 +02:00

316 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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