From 1fe6160b2124c10694545ab17291289ab2f171fc Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Mon, 24 Aug 2026 12:13:51 +0200 Subject: [PATCH] Import aus Monorepo (frischer Start) --- .dockerignore | 11 ++ .gitea/workflows/deploy.yml | 29 ++++ .gitignore | 6 + Dockerfile | 15 ++ README.md | 197 ++++++++++++++++++++++ app.py | 139 ++++++++++++++++ conftest.py | 2 + core.py | 315 ++++++++++++++++++++++++++++++++++++ docker-compose.yml | 24 +++ models.py | 29 ++++ requirements.txt | 5 + tests/test_core.py | 286 ++++++++++++++++++++++++++++++++ 12 files changed, 1058 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 conftest.py create mode 100644 core.py create mode 100644 docker-compose.yml create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 tests/test_core.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..741a927 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +tests/ +conftest.py +.pytest_cache/ +README.md +.git +.gitignore +.gitea +.env +docker-compose.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..3beac39 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: Build & Deploy + +on: + push: + tags: + - 'v*' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Intern klonen (NAT-Hairpin: öffentliche Domain aus dem Job-Container + # nicht erreichbar). + - uses: actions/checkout@v4 + with: + github-server-url: http://gitea:3000 + + # Zustandsloser Service ohne Laufzeit-Env → kein DOTENV/.env nötig. + # Der Docker-Build (pip install) ist der Build. Optionale Tests: hier + # VOR dem Deploy einfügen. + + - name: Tag ermitteln + run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Deployen + run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180 + + - name: Alte Layer aufräumen + run: docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bcff71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +config.json +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1dde194 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Zustandsloser Signatur-Plotter-Service +FROM python:3.12-slim + +WORKDIR /app + +# Abhängigkeiten zuerst (besseres Layer-Caching) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Quellcode +COPY models.py core.py app.py ./ + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3002ffb --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# Signatur-Plotter-Service + +Zustandsloser FastAPI-Service, der für einen Schreibroboter **A4-SVGs** erzeugt, +auf denen handschriftliche Unterschriften an exakt der richtigen Position sitzen. + +Im Druck-PDF stehen an jeder Signaturstelle unsichtbare Marker-Token in +transparenter Schrift (z.B. `§§SIG1§§`). Der Service sucht jeden Token im PDF, +liest dessen Koordinaten aus und platziert die zugeordnete SVG-Unterschrift +relativ dazu. Der Plotter druckt nur die erzeugte SVG – das bereits gedruckte +Dokument liefert den Rest. + +> **Keine Plotter-Kalibrierung im Service.** Mechanischer Versatz wird komplett in +> der Druckersoftware behandelt. Es gibt keine Offset-Logik und keine ENV-Variablen +> für Versatz. + +## Funktionsweise / Geometrie + +- PDF-Punkte werden in mm umgerechnet (`1 pt = 25.4/72 mm`). +- PyMuPDF nutzt den Ursprung oben links, Y wächst nach unten. +- Referenzpunkt ist die **linke Oberkante** des Marker-Tokens (`rect.x0`, `rect.y0`). +- `position: "genau"` → `y_sig = y_token` (Unterschrift sitzt **direkt** am Marker, **Standard**) +- `position: "ueber"` → `y_sig = y_token - abstand_mm - signatur_hoehe` +- `position: "unter"` → `y_sig = y_token + abstand_mm` +- Skalierung gleichmäßig: `skala = breite_mm / svg_originalbreite` + (Originalbreite aus der **viewBox**, nicht aus `width`/`height`). +- Eingebettet wird per ``. + +Die Vorschau legt die fertige A4-Signatur-SVG vektoriell (via PyMuPDF +`convert_to_pdf` + `show_pdf_page`) über eine Kopie der betroffenen Originalseiten +und gibt ein zusammengeführtes PDF zurück. Begründung siehe Kommentar in +[`core.py`](core.py) (`build_preview_pdf`): voll vektoriell, gleiche Geometrie wie +`/render`, ohne zusätzliche Pillow-Abhängigkeit. + +## Job-Definition + +Pro Eintrag. Ein Token darf **mehrfach** im PDF vorkommen (z.B. dieselbe Unterschrift +auf jeder Seite) – jedes Vorkommen wird platziert: + +| Feld | Typ | Default | Bedeutung | +|--------------|-------|---------|--------------------------------------------------| +| `token` | str | – | Unsichtbarer Token im PDF, z.B. `§§SIG1§§` | +| `svg_id` | str | – | Dateiname der hochgeladenen SVG | +| `position` | str | `genau` | `genau` (direkt am Marker), `ueber` oder `unter` | +| `breite_mm` | float | `45` | Zielbreite der Unterschrift | +| `abstand_mm` | float | `8` | Abstand zum Token (nur bei `ueber`/`unter` relevant) | +| `versatz_x_mm` | float | `0` | Feinjustierung links/rechts (+ = rechts, − = links) | +| `versatz_y_mm` | float | `0` | Feinjustierung oben/unten (+ = unten, − = oben) | +| `linienstaerke_mm` | float | `0.35` | Linienstärke der Kontur (Signaturen werden immer als Single-Stroke gezeichnet) | + +Beispiel (`job.json`): + +```json +[ + { "token": "§§SIG1§§", "svg_id": "unterschrift_a.svg", "position": "unter", "breite_mm": 45, "abstand_mm": 8 }, + { "token": "§§SIG2§§", "svg_id": "unterschrift_b.svg", "position": "ueber" } +] +``` + +## Endpunkte + +### `POST /render` +Findet **jedes Vorkommen** jedes Tokens und platziert die zugeordnete SVG an jeder +Stelle. **Rückgabe:** ZIP-Download mit **einer A4-SVG pro betroffener Seite** +(`210×297mm`, `viewBox "0 0 210 297"`), Dateiname `seite_.svg`. Jede Seiten-SVG +enthält alle Signaturen dieser Seite – nur die Unterschriften, Rest leer. + +### `POST /preview` +Gleiche Platzierungslogik. **Rückgabe:** zusammengeführtes PDF mit allen +betroffenen Seiten und der Unterschrift als Overlay – nur zur Sichtkontrolle. + +Übertragung jeweils als `multipart/form-data`: `pdf` als Datei, `svgs` als Dateien +(max. 3) und `job` als JSON-Form-Feld. + +## Fehlerbehandlung + +Es wird nie still übersprungen – Fehler kommen als klare Liste zurück: + +- Token gar nicht gefunden (0 Treffer) → **422** (Mehrfachtreffer sind erlaubt) +- derselbe Token mehrfach in der Job-Definition → **422** +- `svg_id` ohne hochgeladene Datei → **422** +- SVG ohne `viewBox` → **422** +- Mehr als 3 SVGs → **400** +- Ungültiges Job-JSON / fehlendes/leeres PDF → **400** +- Ungültige Felder (`position`, `breite_mm` …) → **422** + +Antwort-Format bei Fehlern: `{ "detail": [ "…", "…" ] }`. + +## Beispiel-curl + +`/render` (lädt das ZIP herunter): + +```bash +curl -X POST http://localhost:8000/render \ + -F "pdf=@dokument.pdf;type=application/pdf" \ + -F "svgs=@unterschrift_a.svg;type=image/svg+xml" \ + -F "svgs=@unterschrift_b.svg;type=image/svg+xml" \ + -F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter","breite_mm":45,"abstand_mm":8},{"token":"§§SIG2§§","svg_id":"unterschrift_b.svg","position":"ueber"}]' \ + -o signaturen.zip +``` + +`/preview` (lädt das Vorschau-PDF herunter): + +```bash +curl -X POST http://localhost:8000/preview \ + -F "pdf=@dokument.pdf;type=application/pdf" \ + -F "svgs=@unterschrift_a.svg;type=image/svg+xml" \ + -F 'job=[{"token":"§§SIG1§§","svg_id":"unterschrift_a.svg","position":"unter"}]' \ + -o vorschau.pdf +``` + +## Lokal starten + +```bash +pip install -r requirements.txt +uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +## Docker (lokal) + +```bash +docker build -t signatur-plotter . +docker run --rm -p 8000:8000 signatur-plotter +``` + +## Deployment hinter Nginx Proxy Manager + +Der Service läuft im selben Docker-Netz wie der NPM (`nginx-proxy-manager_default`) +und wird per Domain **`api-sign.skrift.de`** erreichbar gemacht. Start via Compose: + +```bash +docker compose up -d --build +``` + +Die [`docker-compose.yml`](docker-compose.yml) hängt den Container ins externe Netz +`nginx-proxy-manager_default` (kein Host-Port – NPM proxyt intern). Anschließend im +NPM einen **Proxy Host** anlegen: + +| Feld | Wert | +|------------------|-------------------------------| +| Domain Names | `api-sign.skrift.de` | +| Scheme | `http` | +| Forward Hostname | `skrift-signature-service` | +| Forward Port | `8000` | +| SSL | Let's Encrypt-Zertifikat aktivieren | + +Im Frontend wird die Backend-URL dann auf `https://api-sign.skrift.de` gesetzt +(siehe unten). + +## Tests + +```bash +pip install pytest +pytest +``` + +Die Tests decken Token-Suche, Geometrie (`ueber`/`unter`, viewBox-Offset) und die +Fehlerfälle (Token fehlt/mehrfach, SVG fehlt, keine viewBox) ab. + +## Frontend-Tab in der Skrift-Anwendung + +Der Upload-Bereich ist als eigener Tab **„Signaturen“** in die bestehende +Skrift-FTP-Anwendung integriert (`app/static/index.html`). Er ist über die +Hauptnavigation neben „Manuell drucken“ erreichbar und bietet: + +- Upload für genau **ein PDF** und bis zu **3 SVG-Unterschriften**, +- pro SVG eine Zuordnungszeile (Marker-Token, Position **genau** (Standard) / über / unter, + Breite, Abstand, **X-/Y-Versatz** zur Feinjustierung sowie **Linienstärke**; + Signaturen werden immer als Single-Stroke gezeichnet), +- die zuletzt verwendeten Werte werden **pro Signatur** (Dateiname) im Browser + gespeichert und beim erneuten Hochladen automatisch wiederhergestellt, +- ein **Standard-Template** für den Direktdruck ist in den App-Einstellungen unter + *Zuordnung → Signaturen* festlegbar (wird im Tab vorausgewählt), +- Buttons **Vorschau** (zeigt das Vorschau-PDF inline) und **Rendern** (ZIP-Download), +- **Direkt drucken**: Template + Maschine auswählen und die gerenderten A4-Signaturen + sofort an den Schreibroboter senden, +- eine gut sichtbare Fehlerliste (Token nicht/mehrfach gefunden, fehlende Zuordnung …). + +### Direktdruck-Pfad + +`Direkt drucken` ruft den Skrift-Backend-Endpunkt `POST /api/signature/print` auf. +Dieser rendert die Signaturen serverseitig über den Signatur-Service (`/render`), +entpackt das ZIP und schickt die A4-SVGs mit den Parametern des gewählten Templates +über den vorhandenen Maschinen-Client an den Plotter (gleicher Ablauf wie +„Manuell drucken“, inkl. Formatwechsel-Prüfung und Bestätigung im Tab +„Druckvorgang“). Da dies über den gleichen Origin läuft, ist hierfür kein CORS nötig. + +### Backend-URL setzen + +Die URL des Signatur-Service ist konfigurierbar (Default `http://localhost:8000`, +hinter dem Nginx Proxy Manager z.B. `https://api-sign.skrift.de`): + +- **Im Tab:** Feld *„Signatur-Service (Backend-URL)“* ausfüllen und *Speichern*. +- Persistiert wird in `config.json` unter `app.signature_backend_url` + (Endpunkte `GET`/`POST /api/signature/backend-url` der Skrift-App). + +Damit das Frontend (Port `8765`) den Service (Port `8000`) erreichen darf, ist im +Service **CORS** offen konfiguriert. diff --git a/app.py b/app.py new file mode 100644 index 0000000..8741bbd --- /dev/null +++ b/app.py @@ -0,0 +1,139 @@ +""" +Signatur-Plotter-Service (FastAPI). + +Zustandsloser Service: nimmt ein PDF mit unsichtbaren Marker-Token, bis zu drei +Signatur-SVGs und eine Job-Definition entgegen und erzeugt A4-Plot-SVGs bzw. ein +Vorschau-PDF. Keine Datenbank, keine Persistenz, keine Plotter-Kalibrierung. +""" + +import io +import json +import zipfile +from typing import List + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import ValidationError + +from core import build_preview_pdf, place_all +from models import SignatureJob + +app = FastAPI(title="Signatur-Plotter-Service", version="1.0.0") + +# CORS offen halten, damit das Upload-Frontend (anderer Port/Origin) den Service erreicht. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +MAX_SVGS = 3 + + +# ── Eingaben aufbereiten ──────────────────────────────────────────────── +def _parse_jobs(job_raw: str) -> List[SignatureJob]: + """Parst das Job-Definition-JSON-Formfeld zu einer Liste von SignatureJob.""" + try: + data = json.loads(job_raw) + except json.JSONDecodeError as e: + raise HTTPException(400, f"Job-Definition ist kein gültiges JSON: {e}") + + # Sowohl eine reine Liste als auch ein Wrapper-Objekt akzeptieren. + if isinstance(data, dict): + data = data.get("signaturen") or data.get("jobs") or [data] + if not isinstance(data, list) or not data: + raise HTTPException(400, "Job-Definition muss eine nicht-leere Liste sein.") + + jobs: List[SignatureJob] = [] + errors: List[str] = [] + for i, entry in enumerate(data, start=1): + try: + jobs.append(SignatureJob(**entry)) + except ValidationError as e: + for err in e.errors(): + loc = ".".join(str(x) for x in err["loc"]) + errors.append(f"Eintrag {i} ({loc}): {err['msg']}") + except TypeError: + errors.append(f"Eintrag {i}: ungültiges Format.") + if errors: + # 422 bei Validierungsfehlern der Job-Definition. + raise HTTPException(422, detail=errors) + + # Doppelte Token in der Job-Definition selbst sind ein Eingabefehler. + seen = {} + for j in jobs: + seen[j.token] = seen.get(j.token, 0) + 1 + dups = [t for t, c in seen.items() if c > 1] + if dups: + raise HTTPException(422, detail=[f"Token '{t}' mehrfach in der Job-Definition." for t in dups]) + + return jobs + + +async def _read_svgs(svgs: List[UploadFile]) -> dict: + """Liest die hochgeladenen SVGs in ein {dateiname: bytes}-Dict. Max. 3.""" + if len(svgs) > MAX_SVGS: + raise HTTPException(400, f"Maximal {MAX_SVGS} SVG-Dateien erlaubt (es waren {len(svgs)}).") + out = {} + for f in svgs: + out[f.filename] = await f.read() + return out + + +async def _prepare(pdf: UploadFile, svgs: List[UploadFile], job: str): + """Gemeinsame Aufbereitung für /render und /preview.""" + jobs = _parse_jobs(job) + svg_map = await _read_svgs(svgs) + pdf_bytes = await pdf.read() + if not pdf_bytes: + raise HTTPException(400, "PDF-Datei ist leer.") + pages, errors = place_all(pdf_bytes, svg_map, jobs) + if errors: + # 422: fachliche Validierung (Token nicht gefunden, SVG fehlt, keine viewBox …) + raise HTTPException(422, detail=errors) + return pdf_bytes, pages + + +# ── Endpunkte ─────────────────────────────────────────────────────────── +@app.get("/") +def health(): + return {"service": "Signatur-Plotter-Service", "status": "ok"} + + +@app.post("/render") +async def render( + pdf: UploadFile = File(...), + svgs: List[UploadFile] = File(default=[]), + job: str = Form(...), +): + """Erzeugt pro betroffener Seite eine A4-SVG und liefert alle als ZIP.""" + _, pages = await _prepare(pdf, svgs, job) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for p in pages: + zf.writestr(p.filename, p.a4_svg) + buf.seek(0) + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="signaturen.zip"'}, + ) + + +@app.post("/preview") +async def preview( + pdf: UploadFile = File(...), + svgs: List[UploadFile] = File(default=[]), + job: str = Form(...), +): + """Erzeugt ein Vorschau-PDF mit allen betroffenen Seiten und Signatur-Overlay.""" + pdf_bytes, pages = await _prepare(pdf, svgs, job) + out_pdf = build_preview_pdf(pdf_bytes, pages) + return StreamingResponse( + io.BytesIO(out_pdf), + media_type="application/pdf", + headers={"Content-Disposition": 'inline; filename="vorschau.pdf"'}, + ) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..c0c7b29 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# Leer – sorgt dafür, dass pytest das Service-Verzeichnis auf sys.path legt, +# damit `import core` / `import models` in den Tests funktioniert. diff --git a/core.py b/core.py new file mode 100644 index 0000000..4bac946 --- /dev/null +++ b/core.py @@ -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-, 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}"] + + 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73d8fd9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +# Auto-Deploy per Tag-Push – siehe DEPLOY.md. Zustandsloser Service, keine +# Volumes, keine Laufzeit-Env. NPM proxyt api-sign.skrift.de intern an +# skrift-signature-service:8000 (kein Port-Mapping). +name: skrift-signature-service + +services: + app: + image: skrift-signature-service:${IMAGE_TAG:-latest} + build: . + container_name: skrift-signature-service + restart: unless-stopped + networks: + - nginx-proxy-manager_default + # Gate für `--wait`; „/" ist der Health-Endpoint des FastAPI-Services. + # Python-Image → kein node/curl, daher urllib. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/').status==200 else 1)"] + interval: 60s + timeout: 5s + retries: 3 + +networks: + nginx-proxy-manager_default: + external: true diff --git a/models.py b/models.py new file mode 100644 index 0000000..5ad9e75 --- /dev/null +++ b/models.py @@ -0,0 +1,29 @@ +"""Pydantic-Modelle für die Job-Definition des Signatur-Plotter-Service.""" + +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + + +class SignatureJob(BaseModel): + """Ein Eintrag der Job-Definition: ordnet genau einen Marker-Token einer SVG zu.""" + + token: str # unsichtbarer Token im PDF, z.B. "§§SIG1§§" + svg_id: str # Dateiname der hochgeladenen SVG + # Lage relativ zum Token. "genau" = direkt an der Marker-Position (Standard), + # "ueber"/"unter" = mit Abstand darüber/darunter. + position: Literal["genau", "ueber", "unter"] = "genau" + breite_mm: float = Field(default=45.0, gt=0) # Zielbreite der Unterschrift + abstand_mm: float = Field(default=8.0, ge=0) # Abstand (nur bei ueber/unter relevant) + # Feinjustierung relativ zur berechneten Position (darf negativ sein): + versatz_x_mm: float = 0.0 # + = nach rechts, − = nach links + versatz_y_mm: float = 0.0 # + = nach unten, − = nach oben + # Signaturen werden grundsätzlich als Single-Stroke gezeichnet (keine Füllung). + linienstaerke_mm: float = Field(default=0.35, gt=0) # Linienstärke der Kontur + + @field_validator("token", "svg_id") + @classmethod + def _not_blank(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("darf nicht leer sein") + return v diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f35d4e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn>=0.29.0 +PyMuPDF>=1.24.0 +python-multipart>=0.0.9 +pydantic>=2.0.0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..8acdf66 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,286 @@ +"""Minimale Tests für die Kernlogik: Token-Suche, Geometrie, Fehlerfälle.""" + +import re + +import fitz +import pytest + +from core import ( + PT_TO_MM, + PlacementError, + build_a4_svg, + find_token_occurrences, + parse_svg, + place_all, + signatur_geometrie, +) +from models import SignatureJob + +# ── Test-Hilfen ───────────────────────────────────────────────────────── +SVG_OK = ( + b'' + b'' +) +SVG_NO_VIEWBOX = ( + b'' + b'' +) + + +def make_pdf(tokens_at): + """Erzeugt ein A4-PDF (1 Seite) mit den Token an gegebenen Punkt-Positionen.""" + doc = fitz.open() + page = doc.new_page(width=595.0, height=842.0) # A4 in pt + for token, (x, y) in tokens_at: + page.insert_text((x, y), token, fontsize=11) + pdf_bytes = doc.tobytes() + doc.close() + return pdf_bytes + + +def _translate_from_svg(a4_svg): + """Liest tx, ty aus dem ersten translate(...) des A4-SVG.""" + m = re.search(r"translate\(([-\d.]+),([-\d.]+)\)", a4_svg) + assert m, "kein translate gefunden" + return float(m.group(1)), float(m.group(2)) + + +def _scale_from_svg(a4_svg): + m = re.search(r"scale\(([-\d.]+)\)", a4_svg) + assert m, "kein scale gefunden" + return float(m.group(1)) + + +# ── viewBox / SVG ─────────────────────────────────────────────────────── +def test_parse_svg_liest_viewbox(): + vb = parse_svg(SVG_OK) + assert (vb.minx, vb.miny, vb.vw, vb.vh) == (0.0, 0.0, 100.0, 40.0) + assert "' + b'' + ) + vb = parse_svg(svg) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + job = SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter") + a4 = build_a4_svg(vb, rect, job) + tx, ty = _translate_from_svg(a4) + skala = _scale_from_svg(a4) + # tx = x_sig - minx*skala + assert tx == pytest.approx(72.0 * PT_TO_MM - 10.0 * skala, abs=1e-3) + + +def test_versatz_verschiebt_links_rechts_oben_unten(): + vb = parse_svg(SVG_OK) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + base = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau") + moved = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau", + versatz_x_mm=5.0, versatz_y_mm=-3.0) + a4_base = build_a4_svg(vb, rect, base) + a4_moved = build_a4_svg(vb, rect, moved) + bx, by = _translate_from_svg(a4_base) + mx, my = _translate_from_svg(a4_moved) + assert mx == pytest.approx(bx + 5.0, abs=1e-3) # +x → nach rechts + assert my == pytest.approx(by - 3.0, abs=1e-3) # −y → nach oben + + +def test_immer_single_stroke_fill_none_und_stroke(): + """Signaturen werden immer als Single-Stroke gezeichnet (fill:none + stroke).""" + vb = parse_svg(SVG_OK) + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + job = SignatureJob(token="§§S§§", svg_id="s.svg", position="genau", + breite_mm=50.0, linienstaerke_mm=0.5) + a4 = build_a4_svg(vb, rect, job) + assert 'fill="none"' in a4 + assert "stroke=" in a4 and "stroke-width=" in a4 + # stroke-width in viewBox-Einheiten: 0.5mm / skala (skala = 50/100 = 0.5) = 1.0 + import re + sw = float(re.search(r'stroke-width="([\d.]+)"', a4).group(1)) + assert sw == pytest.approx(1.0, abs=1e-3) + + +def test_root_transform_wird_nicht_uebernommen(): + """Ein transform auf der äußersten darf die Platzierung nicht verfälschen.""" + svg = ( + b'' + ) + vb = parse_svg(svg) + assert "transform" not in vb.group_attrs # nicht mitgeführt + assert vb.group_attrs.get("fill") == "blue" # echtes Styling bleibt + rect = fitz.Rect(72.0, 72.0, 120.0, 84.0) + a4 = build_a4_svg(vb, rect, SignatureJob(token="§§S§§", svg_id="s.svg")) + # Genau ein transform (die Positionierung), nicht zwei. + assert a4.count("transform=") == 1 + + +# ── Token-Suche ───────────────────────────────────────────────────────── +def test_find_token_einmal(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + occ = find_token_occurrences(doc, "§§SIG1§§") + doc.close() + assert len(occ) == 1 + page_index, rect = occ[0] + assert page_index == 0 + assert rect.x0 == pytest.approx(100.0, abs=2.0) # nahe Einfügepunkt + + +def test_find_token_nicht_gefunden(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + assert find_token_occurrences(doc, "§§SIG9§§") == [] + doc.close() + + +def test_find_token_mehrfach_erlaubt(): + # Derselbe Token mehrfach (z.B. Unterschrift auf mehreren Stellen) → alle Treffer. + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0)), ("§§SIG1§§", (100.0, 400.0))]) + doc = fitz.open(stream=pdf, filetype="pdf") + occ = find_token_occurrences(doc, "§§SIG1§§") + doc.close() + assert len(occ) == 2 + + +# ── place_all: Gesamtfluss + Fehlerliste ──────────────────────────────── +def make_multipage_pdf(per_page_tokens): + """Erzeugt ein A4-PDF mit mehreren Seiten; pro Seite eine Liste (token, (x,y)).""" + doc = fitz.open() + for tokens in per_page_tokens: + page = doc.new_page(width=595.0, height=842.0) + for token, (x, y) in tokens: + page.insert_text((x, y), token, fontsize=11) + data = doc.tobytes() + doc.close() + return data + + +def test_place_all_erfolg(): + pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))]) + jobs = [SignatureJob(token="§§SIG1§§", svg_id="unterschrift.svg", position="unter")] + pages, errors = place_all(pdf, {"unterschrift.svg": SVG_OK}, jobs) + assert errors == [] + assert len(pages) == 1 + p = pages[0] + assert p.filename == "seite_1.svg" + assert p.tokens == ["§§SIG1§§"] + assert p.a4_svg.startswith("