Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/ (skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion, AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
Docker/signature-service/.dockerignore
Normal file
6
Docker/signature-service/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
tests/
|
||||
conftest.py
|
||||
.pytest_cache/
|
||||
README.md
|
||||
15
Docker/signature-service/Dockerfile
Normal file
15
Docker/signature-service/Dockerfile
Normal 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
Docker/signature-service/README.md
Normal file
197
Docker/signature-service/README.md
Normal 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
Docker/signature-service/app.py
Normal file
139
Docker/signature-service/app.py
Normal 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
Docker/signature-service/conftest.py
Normal file
2
Docker/signature-service/conftest.py
Normal 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
Docker/signature-service/core.py
Normal file
315
Docker/signature-service/core.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Kernlogik des Signatur-Plotter-Service.
|
||||
|
||||
Hier liegt die gemeinsame Logik, die /render und /preview beide nutzen:
|
||||
1. Marker-Token im PDF finden (eindeutig) und Koordinaten auslesen.
|
||||
2. Die zugeordnete SVG geometrisch korrekt auf einer A4-SVG platzieren.
|
||||
|
||||
Der Service ist zustandslos: alles arbeitet auf den übergebenen Bytes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Tuple
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from models import SignatureJob
|
||||
|
||||
# ── Konstanten ──────────────────────────────────────────────────────────
|
||||
PT_TO_MM = 25.4 / 72.0 # 1 pt = 25.4/72 mm
|
||||
A4_WIDTH_MM = 210.0
|
||||
A4_HEIGHT_MM = 297.0
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||
|
||||
# Saubere Namensräume beim Serialisieren (kein ns0:-Präfix im Output)
|
||||
ET.register_namespace("", SVG_NS)
|
||||
ET.register_namespace("xlink", XLINK_NS)
|
||||
|
||||
# Strukturelle Attribute der Wurzel-<svg>, die NICHT auf die Gruppe übernommen werden
|
||||
_SKIP_ROOT_ATTRS = {
|
||||
"viewbox", "width", "height", "xmlns", "x", "y", "id",
|
||||
"version", "preserveaspectratio", "baseprofile",
|
||||
# transform auf der äußersten <svg> wird laut Spec ignoriert – darf daher NICHT
|
||||
# auf die Gruppe übernommen werden, sonst verzieht/verschiebt es die Signatur.
|
||||
"transform",
|
||||
}
|
||||
|
||||
|
||||
class PlacementError(Exception):
|
||||
"""Eingabe-/Validierungsfehler, der dem Aufrufer als Fehlermeldung gemeldet wird."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViewBox:
|
||||
"""Aus der SVG gelesene viewBox plus eingebetteter Inhalt."""
|
||||
|
||||
minx: float
|
||||
miny: float
|
||||
vw: float
|
||||
vh: float
|
||||
inner: str # serialisierte Kind-Elemente der <svg>
|
||||
group_attrs: dict = field(default_factory=dict) # übernommene Styling-Attribute
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageRender:
|
||||
"""Ergebnis pro betroffener Seite: eine A4-SVG mit allen Signaturen dieser Seite."""
|
||||
|
||||
page_index: int # 0-basiert
|
||||
a4_svg: str # fertige A4-SVG als String
|
||||
filename: str # seite_<n>.svg
|
||||
tokens: list = field(default_factory=list) # welche Token auf der Seite platziert wurden
|
||||
|
||||
|
||||
# ── SVG einlesen ────────────────────────────────────────────────────────
|
||||
def parse_svg(svg_bytes: bytes) -> ViewBox:
|
||||
"""Liest viewBox und Inhalt einer SVG. Wirft PlacementError bei fehlender viewBox."""
|
||||
try:
|
||||
root = ET.fromstring(svg_bytes)
|
||||
except ET.ParseError as e:
|
||||
raise PlacementError(f"SVG nicht lesbar ({e}).")
|
||||
|
||||
# viewBox case-insensitiv suchen (XML ist eigentlich case-sensitiv, aber wir sind tolerant)
|
||||
vb_raw = root.get("viewBox") or root.get("viewbox")
|
||||
if not vb_raw:
|
||||
raise PlacementError("SVG ohne viewBox – Skalierung nicht möglich.")
|
||||
|
||||
parts = re.split(r"[\s,]+", vb_raw.strip())
|
||||
if len(parts) != 4:
|
||||
raise PlacementError("viewBox hat nicht genau 4 Werte.")
|
||||
try:
|
||||
minx, miny, vw, vh = (float(p) for p in parts)
|
||||
except ValueError:
|
||||
raise PlacementError("viewBox enthält ungültige Zahlen.")
|
||||
if vw <= 0 or vh <= 0:
|
||||
raise PlacementError("viewBox-Breite/Höhe muss > 0 sein.")
|
||||
|
||||
# Inhalt der <svg> (alle Kinder) als String – Wrapper-<svg> entfällt, wir nutzen <g>.
|
||||
inner = "".join(ET.tostring(child, encoding="unicode") for child in root)
|
||||
|
||||
# Styling-Attribute der Wurzel (z.B. fill/stroke) auf die spätere Gruppe übertragen,
|
||||
# damit als Präsentationsattribut gesetzte Standardfarben nicht verloren gehen.
|
||||
group_attrs = {}
|
||||
for k, v in root.attrib.items():
|
||||
local = k.split("}")[-1].lower()
|
||||
if local not in _SKIP_ROOT_ATTRS:
|
||||
group_attrs[k.split("}")[-1]] = v
|
||||
|
||||
return ViewBox(minx, miny, vw, vh, inner, group_attrs)
|
||||
|
||||
|
||||
# ── Token-Suche ─────────────────────────────────────────────────────────
|
||||
def find_token_occurrences(doc: fitz.Document, token: str) -> List[Tuple[int, fitz.Rect]]:
|
||||
"""
|
||||
Sucht den Token im gesamten PDF und liefert ALLE Vorkommen.
|
||||
|
||||
Der Token darf mehrfach vorkommen (z.B. dieselbe Unterschrift auf jeder Seite) –
|
||||
jedes Vorkommen wird platziert. Rückgabe: Liste von (page_index, rect), evtl. leer.
|
||||
"""
|
||||
hits: List[Tuple[int, fitz.Rect]] = []
|
||||
for page_index in range(doc.page_count):
|
||||
for rect in doc[page_index].search_for(token):
|
||||
hits.append((page_index, rect))
|
||||
return hits
|
||||
|
||||
|
||||
# ── Geometrie ───────────────────────────────────────────────────────────
|
||||
def signatur_geometrie(x0_pt: float, y0_pt: float, vb: ViewBox,
|
||||
position: str, breite_mm: float, abstand_mm: float,
|
||||
versatz_x_mm: float = 0.0, versatz_y_mm: float = 0.0):
|
||||
"""
|
||||
Berechnet Platzierung der Signatur in mm.
|
||||
|
||||
Referenzpunkt ist die linke Oberkante des Tokens (rect.x0, rect.y0).
|
||||
PyMuPDF: Ursprung oben links, Y wächst nach unten.
|
||||
versatz_x_mm/versatz_y_mm verschieben das Ergebnis zusätzlich
|
||||
(+x = rechts, +y = unten) zur Feinjustierung.
|
||||
|
||||
Rückgabe: (x_sig_mm, y_sig_mm, skala, signatur_hoehe_mm)
|
||||
"""
|
||||
# Token-Position von Punkten in mm
|
||||
x_token_mm = x0_pt * PT_TO_MM
|
||||
y_token_mm = y0_pt * PT_TO_MM
|
||||
|
||||
# Gleichmäßige Skalierung anhand der viewBox-Breite (nicht width/height!)
|
||||
skala = breite_mm / vb.vw
|
||||
signatur_hoehe_mm = vb.vh * skala
|
||||
|
||||
if position == "ueber":
|
||||
y_sig = y_token_mm - abstand_mm - signatur_hoehe_mm
|
||||
elif position == "unter":
|
||||
y_sig = y_token_mm + abstand_mm
|
||||
else: # "genau" – Signatur sitzt direkt an der Marker-Position (kein Abstand)
|
||||
y_sig = y_token_mm
|
||||
|
||||
# Feinjustierung links/rechts/oben/unten
|
||||
x_sig = x_token_mm + versatz_x_mm
|
||||
y_sig = y_sig + versatz_y_mm
|
||||
|
||||
return x_sig, y_sig, skala, signatur_hoehe_mm
|
||||
|
||||
|
||||
def build_signature_group(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
|
||||
"""Erzeugt die positionierte <g>-Gruppe einer einzelnen Signatur (ohne A4-Wrapper)."""
|
||||
x_sig, y_sig, skala, _ = signatur_geometrie(
|
||||
rect.x0, rect.y0, vb, job.position, job.breite_mm, job.abstand_mm,
|
||||
job.versatz_x_mm, job.versatz_y_mm,
|
||||
)
|
||||
|
||||
# viewBox-Offset (minx/miny) in die Translation einrechnen, damit die linke
|
||||
# Oberkante des Signatur-Inhalts exakt auf (x_sig, y_sig) liegt.
|
||||
tx = x_sig - vb.minx * skala
|
||||
ty = y_sig - vb.miny * skala
|
||||
|
||||
# Positionierung und Signatur-eigene Attribute getrennt halten:
|
||||
# äußere Gruppe = nur translate/scale, innere Gruppe = Styling der Original-<svg>
|
||||
# (fill/stroke …). So kollidiert nie ein mitgeführtes Attribut mit dem transform.
|
||||
attrs = dict(vb.group_attrs)
|
||||
|
||||
# Signaturen werden immer als Single-Stroke gezeichnet: offene Kurven als Linie
|
||||
# statt gefüllt. stroke-width in viewBox-Einheiten, damit nach scale(skala) die
|
||||
# gewünschte mm-Stärke herauskommt. Greift auf Pfade ohne eigene fill/stroke-Angabe.
|
||||
sw = job.linienstaerke_mm / skala if skala else job.linienstaerke_mm
|
||||
stroke = attrs.get("stroke")
|
||||
if not stroke or stroke == "none":
|
||||
stroke = "#000000"
|
||||
attrs.update({
|
||||
"fill": "none",
|
||||
"stroke": stroke,
|
||||
"stroke-width": f"{sw:.4f}",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
})
|
||||
|
||||
attr = "".join(f' {k}="{_xml_escape(str(v))}"' for k, v in attrs.items())
|
||||
inner_group = f"<g{attr}>{vb.inner}</g>" if attr else vb.inner
|
||||
return (
|
||||
f'<g transform="translate({tx:.4f},{ty:.4f}) scale({skala:.6f})">'
|
||||
f"{inner_group}</g>"
|
||||
)
|
||||
|
||||
|
||||
def wrap_a4(groups: List[str]) -> str:
|
||||
"""Verpackt Signatur-Gruppen in eine A4-Plot-SVG (nur Signaturen, Rest transparent)."""
|
||||
return (
|
||||
f'<svg xmlns="{SVG_NS}" xmlns:xlink="{XLINK_NS}" '
|
||||
f'width="{A4_WIDTH_MM}mm" height="{A4_HEIGHT_MM}mm" '
|
||||
f'viewBox="0 0 {A4_WIDTH_MM:g} {A4_HEIGHT_MM:g}">'
|
||||
f"{''.join(groups)}</svg>"
|
||||
)
|
||||
|
||||
|
||||
def build_a4_svg(vb: ViewBox, rect: fitz.Rect, job: SignatureJob) -> str:
|
||||
"""Bequemlichkeit: A4-SVG mit genau einer Signatur (v.a. für Tests)."""
|
||||
return wrap_a4([build_signature_group(vb, rect, job)])
|
||||
|
||||
|
||||
def _xml_escape(v: str) -> str:
|
||||
return (v.replace("&", "&").replace('"', """)
|
||||
.replace("<", "<").replace(">", ">"))
|
||||
|
||||
|
||||
# ── Hauptlogik: alle Jobs platzieren ────────────────────────────────────
|
||||
def place_all(pdf_bytes: bytes, svgs: dict, jobs: List[SignatureJob]):
|
||||
"""
|
||||
Platziert alle Jobs. Sammelt ALLE Fehler (kein stilles Überspringen).
|
||||
|
||||
Ein Token darf MEHRFACH vorkommen (Unterschrift auf mehreren Seiten) – jedes
|
||||
Vorkommen wird platziert. Pro betroffener Seite entsteht eine A4-SVG, die alle
|
||||
Signaturen dieser Seite enthält. Nur 0 Treffer ist ein Fehler.
|
||||
|
||||
Rückgabe: (pages, errors)
|
||||
pages: List[PageRender] – je betroffene Seite eine A4-SVG
|
||||
errors: List[str] – leer, wenn alles geklappt hat
|
||||
"""
|
||||
errors: List[str] = []
|
||||
# Pro Seite die platzierten Signatur-Gruppen sammeln: page_index -> [(token, group)]
|
||||
page_items: dict = {}
|
||||
|
||||
try:
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as e:
|
||||
return [], [f"PDF konnte nicht geöffnet werden: {e}"]
|
||||
|
||||
try:
|
||||
for job in jobs:
|
||||
# 1. Ist die zugeordnete SVG hochgeladen?
|
||||
if job.svg_id not in svgs:
|
||||
errors.append(
|
||||
f"Token '{job.token}': SVG '{job.svg_id}' wurde nicht hochgeladen."
|
||||
)
|
||||
continue
|
||||
|
||||
# 2. viewBox lesbar?
|
||||
try:
|
||||
vb = parse_svg(svgs[job.svg_id])
|
||||
except PlacementError as e:
|
||||
errors.append(f"Token '{job.token}': {e}")
|
||||
continue
|
||||
|
||||
# 3. Token im PDF vorhanden? (Mehrfachtreffer sind ausdrücklich erlaubt.)
|
||||
occurrences = find_token_occurrences(doc, job.token)
|
||||
if not occurrences:
|
||||
errors.append(f"Token '{job.token}' nicht im PDF gefunden.")
|
||||
continue
|
||||
|
||||
for page_index, rect in occurrences:
|
||||
group = build_signature_group(vb, rect, job)
|
||||
# rect.x0 (x-Position des Tokens) mitführen, um später links→rechts zu ordnen
|
||||
page_items.setdefault(page_index, []).append((rect.x0, job.token, group))
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
if errors:
|
||||
return [], errors
|
||||
|
||||
pages: List[PageRender] = []
|
||||
for page_index in sorted(page_items):
|
||||
# Signaturen einer Seite links→rechts sortieren: der Plotter schreibt in
|
||||
# dieser Richtung, also linke Signatur immer vor der rechten abarbeiten.
|
||||
items = sorted(page_items[page_index], key=lambda t: t[0])
|
||||
a4 = wrap_a4([group for _, _, group in items])
|
||||
pages.append(PageRender(
|
||||
page_index=page_index,
|
||||
a4_svg=a4,
|
||||
filename=f"seite_{page_index + 1}.svg",
|
||||
tokens=[token for _, token, _ in items],
|
||||
))
|
||||
return pages, errors
|
||||
|
||||
|
||||
# ── Vorschau (vektoriell zusammengeführtes PDF) ─────────────────────────
|
||||
def build_preview_pdf(pdf_bytes: bytes, pages: List[PageRender]) -> bytes:
|
||||
"""
|
||||
Baut ein Vorschau-PDF mit allen betroffenen Seiten und den Signaturen als Overlay.
|
||||
|
||||
Gewählter Weg: Die fertige A4-Signatur-SVG (pro Seite) wird via PyMuPDF nach PDF
|
||||
konvertiert und mit show_pdf_page() deckungsgleich über eine Kopie der
|
||||
Originalseite gelegt. Begründung: bleibt voll vektoriell (keine Rasterungs-
|
||||
Artefakte), nutzt exakt dieselbe Geometrie wie /render und kommt ohne zusätzliche
|
||||
Pillow-Abhängigkeit aus. Es wird vorausgesetzt, dass die PDF-Seiten A4 sind.
|
||||
"""
|
||||
src = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
out = fitz.open()
|
||||
|
||||
try:
|
||||
for p in sorted(pages, key=lambda x: x.page_index):
|
||||
# Originalseite 1:1 übernehmen …
|
||||
out.insert_pdf(src, from_page=p.page_index, to_page=p.page_index)
|
||||
page = out[-1]
|
||||
# … und die A4-Signatur-SVG dieser Seite darüberlegen.
|
||||
sig_svg = fitz.open(stream=p.a4_svg.encode("utf-8"), filetype="svg")
|
||||
try:
|
||||
sig_pdf = fitz.open("pdf", sig_svg.convert_to_pdf())
|
||||
page.show_pdf_page(page.rect, sig_pdf, 0)
|
||||
sig_pdf.close()
|
||||
finally:
|
||||
sig_svg.close()
|
||||
data = out.tobytes()
|
||||
finally:
|
||||
out.close()
|
||||
src.close()
|
||||
return data
|
||||
30
Docker/signature-service/docker-compose.yml
Normal file
30
Docker/signature-service/docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
# Signatur-Plotter-Service hinter Nginx Proxy Manager.
|
||||
#
|
||||
# Der Container hängt im selben Docker-Netz wie der NPM (nginx-proxy-manager_default)
|
||||
# und wird NICHT direkt nach außen veröffentlicht – NPM terminiert TLS und leitet die
|
||||
# Domain api-sign.skrift.de an diesen Container weiter.
|
||||
#
|
||||
# Im NPM als Proxy Host anlegen:
|
||||
# Domain Names : api-sign.skrift.de
|
||||
# Scheme : http
|
||||
# Forward Hostname : skrift-signature-service (= container_name)
|
||||
# Forward Port : 8000
|
||||
# Websockets/Block : nach Bedarf, SSL-Zertifikat (Let's Encrypt) aktivieren.
|
||||
|
||||
services:
|
||||
signature-service:
|
||||
build: .
|
||||
image: skrift-signature-service
|
||||
container_name: skrift-signature-service
|
||||
restart: unless-stopped
|
||||
# Nur im Docker-Netz erreichbar (kein host-port nötig, NPM proxyt intern).
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
- npm
|
||||
|
||||
networks:
|
||||
# Bereits vorhandenes Netz des Nginx Proxy Managers mitbenutzen.
|
||||
npm:
|
||||
name: nginx-proxy-manager_default
|
||||
external: true
|
||||
29
Docker/signature-service/models.py
Normal file
29
Docker/signature-service/models.py
Normal 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
Docker/signature-service/requirements.txt
Normal file
5
Docker/signature-service/requirements.txt
Normal 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
Docker/signature-service/tests/test_core.py
Normal file
286
Docker/signature-service/tests/test_core.py
Normal 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)
|
||||
Reference in New Issue
Block a user