319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""
|
||
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}"]
|
||
|
||
total_pages = doc.page_count
|
||
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
|
||
|
||
# Eine A4-SVG JE PDF-Seite – auch für Seiten OHNE Marker (leere SVG), damit der
|
||
# Plotter jede Seite einzieht und die Reihenfolge zum Dokument passt.
|
||
pages: List[PageRender] = []
|
||
for page_index in range(total_pages):
|
||
# Signaturen einer Seite links→rechts sortieren: der Plotter schreibt in
|
||
# dieser Richtung, also linke Signatur immer vor der rechten abarbeiten.
|
||
items = sorted(page_items.get(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
|