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:
315
Docker/signature-service/core.py
Normal file
315
Docker/signature-service/core.py
Normal 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("&", "&").replace('"', """)
|
||||
.replace("<", "<").replace(">", ">"))
|
||||
|
||||
|
||||
# ── 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
|
||||
Reference in New Issue
Block a user