Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 1m15s

This commit is contained in:
Lucas Orth
2026-08-24 12:13:51 +02:00
commit 1fe6160b21
12 changed files with 1058 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
__pycache__/
*.pyc
tests/
conftest.py
.pytest_cache/
README.md
.git
.gitignore
.gitea
.env
docker-compose.yml

View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
venv/
config.json
.env

15
Dockerfile Normal file
View File

@@ -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"]

197
README.md Normal file
View File

@@ -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 `<g transform="translate(x,y) scale(skala)">`.
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_<n>.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.

139
app.py Normal file
View File

@@ -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"'},
)

2
conftest.py Normal file
View File

@@ -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.

315
core.py Normal file
View 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("&", "&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

24
docker-compose.yml Normal file
View File

@@ -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

29
models.py Normal file
View File

@@ -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

5
requirements.txt Normal file
View File

@@ -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

286
tests/test_core.py Normal file
View File

@@ -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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40">'
b'<path d="M0 20 L100 20" stroke="black"/></svg>'
)
SVG_NO_VIEWBOX = (
b'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40">'
b'<path d="M0 20 L100 20"/></svg>'
)
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 "<path" in vb.inner
def test_parse_svg_ohne_viewbox_fehler():
with pytest.raises(PlacementError):
parse_svg(SVG_NO_VIEWBOX)
# ── Geometrie ───────────────────────────────────────────────────────────
def test_geometrie_unter():
vb = parse_svg(SVG_OK)
# Token bei y0 = 72 pt = 25.4 mm
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="unter", breite_mm=45.0, abstand_mm=8.0
)
assert skala == pytest.approx(45.0 / 100.0)
assert h == pytest.approx(40.0 * 0.45) # 18 mm
assert x_mm == pytest.approx(72.0 * PT_TO_MM) # 25.4 mm
# unter: y_token + abstand
assert y_sig == pytest.approx(72.0 * PT_TO_MM + 8.0) # 33.4 mm
def test_geometrie_genau():
vb = parse_svg(SVG_OK)
# "genau": Signatur sitzt exakt an der Marker-Position (kein Abstand).
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="genau", breite_mm=45.0, abstand_mm=8.0
)
assert y_sig == pytest.approx(72.0 * PT_TO_MM) # == y_token, abstand ignoriert
assert x_mm == pytest.approx(72.0 * PT_TO_MM)
def test_position_default_ist_genau():
job = SignatureJob(token="§§SIG1§§", svg_id="s.svg")
assert job.position == "genau"
def test_geometrie_ueber():
vb = parse_svg(SVG_OK)
x_mm, y_sig, skala, h = signatur_geometrie(
x0_pt=72.0, y0_pt=72.0, vb=vb, position="ueber", breite_mm=45.0, abstand_mm=8.0
)
# ueber: y_token - abstand - hoehe
expected = 72.0 * PT_TO_MM - 8.0 - h
assert y_sig == pytest.approx(expected)
# über muss deutlich höher (kleineres y) liegen als unter
assert y_sig < 72.0 * PT_TO_MM
def test_geometrie_viewbox_offset_in_translate():
"""Ein viewBox-Offset muss in die Translation eingerechnet werden."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 5 100 40">'
b'<path d="M10 25 L110 25"/></svg>'
)
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 <svg> darf die Platzierung nicht verfälschen."""
svg = (
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40" '
b'transform="rotate(45)" fill="blue"><path d="M0 20 L100 20"/></svg>'
)
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("<svg")
assert 'viewBox="0 0 210 297"' in p.a4_svg
def test_place_all_token_auf_mehreren_seiten():
"""Derselbe Token auf mehreren Seiten → pro Seite eine A4-SVG."""
pdf = make_multipage_pdf([
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
[("[[sign]]", (100.0, 300.0))],
])
jobs = [SignatureJob(token="[[sign]]", svg_id="u.svg", position="genau")]
pages, errors = place_all(pdf, {"u.svg": SVG_OK}, jobs)
assert errors == []
assert [p.filename for p in pages] == ["seite_1.svg", "seite_2.svg", "seite_3.svg"]
def test_place_all_zwei_tokens_pro_seite_kombiniert():
"""Zwei verschiedene Token auf jeder Seite → eine A4-SVG pro Seite mit beiden."""
pdf = make_multipage_pdf([
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
[("[[fk]]", (100.0, 600.0)), ("[[tv]]", (300.0, 600.0))],
])
jobs = [
SignatureJob(token="[[fk]]", svg_id="a.svg", position="genau"),
SignatureJob(token="[[tv]]", svg_id="b.svg", position="genau"),
]
pages, errors = place_all(pdf, {"a.svg": SVG_OK, "b.svg": SVG_OK}, jobs)
assert errors == []
assert len(pages) == 2
# Jede Seiten-SVG enthält beide Signaturen (zwei positionierte Gruppen).
for p in pages:
assert sorted(p.tokens) == ["[[fk]]", "[[tv]]"]
assert p.a4_svg.count("translate(") == 2
def test_place_all_svg_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter")]
placements, errors = place_all(pdf, {}, jobs)
assert placements == []
assert any("nicht hochgeladen" in e for e in errors)
def test_place_all_token_fehlt():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG2§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert any("nicht im PDF" in e for e in errors)
def test_place_all_sammelt_mehrere_fehler():
"""Mehrere fehlerhafte Jobs → alle Fehler werden gemeldet, nicht nur der erste."""
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [
SignatureJob(token="§§SIGX§§", svg_id="s.svg", position="unter"), # Token fehlt
SignatureJob(token="§§SIG1§§", svg_id="fehlt.svg", position="unter"), # SVG fehlt
]
placements, errors = place_all(pdf, {"s.svg": SVG_OK}, jobs)
assert placements == []
assert len(errors) == 2
def test_place_all_svg_ohne_viewbox():
pdf = make_pdf([("§§SIG1§§", (100.0, 200.0))])
jobs = [SignatureJob(token="§§SIG1§§", svg_id="s.svg", position="unter")]
placements, errors = place_all(pdf, {"s.svg": SVG_NO_VIEWBOX}, jobs)
assert placements == []
assert any("viewBox" in e for e in errors)