""" 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-, 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 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 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_.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 (alle Kinder) als String – Wrapper- entfällt, wir nutzen . 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 -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- # (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"{vb.inner}" if attr else vb.inner return ( f'' f"{inner_group}" ) def wrap_a4(groups: List[str]) -> str: """Verpackt Signatur-Gruppen in eine A4-Plot-SVG (nur Signaturen, Rest transparent).""" return ( f'' f"{''.join(groups)}" ) 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