Files
Lucas Orth aca2f67f97
All checks were successful
Build & Deploy / deploy (push) Successful in 2m3s
render: eine A4-SVG je PDF-Seite (auch leere) + /render-json fuer die Auftrags-Anlage
2026-08-27 11:05:44 +02:00

152 lines
5.3 KiB
Python

"""
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("/render-json")
async def render_json(
pdf: UploadFile = File(...),
svgs: List[UploadFile] = File(default=[]),
job: str = Form(...),
):
"""Wie /render, aber als JSON: eine A4-SVG je PDF-Seite (leer wo kein Marker).
Für die Auftrags-Anlage, die die Seiten direkt in den Ausgabeordner schreibt."""
_, pages = await _prepare(pdf, svgs, job)
return {"pages": [{"filename": p.filename, "svg": p.a4_svg} for p in pages]}
@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"'},
)