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:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
tests/
conftest.py
.pytest_cache/
README.md

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

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.

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

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.

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

View 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

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

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

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)

View File

@@ -0,0 +1,30 @@
# Dependencies (werden im Image per npm install neu erzeugt)
node_modules
npm-debug.log
# Secrets & lokale Config (kommen zur Laufzeit via docker-compose environment)
.env
.env.example
# Git
.git
.gitignore
# Laufzeit-/generierte Daten (output ist in Prod ein Volume)
cache
output
server.log
*.log
# Doku
*.md
*.txt
# Dev-/Test-Artefakte
bruno-tests
deploy.sh
test-*.js
test-*.json
test-*.sh
generate-9-orders.js
prepare-deployment.js

View File

@@ -0,0 +1,21 @@
# Environment Configuration Example
# Copy this file to .env and fill in your values
# Node Environment
NODE_ENV=production
# Scriptalizer API Configuration
SCRIPTALIZER_LICENSE_KEY=your-license-key-here
SCRIPTALIZER_ERR_FREQUENCY=0
# Preview Configuration
# No batch size limit - frontend can send unlimited letters
# Backend automatically splits into 25-letter batches for Scriptalizer API
# No cache expiration
# No rate limiting
# Server Configuration
PORT=4000
# API Authentication (optional)
API_TOKEN=your-api-token-here

7
Docker/skrift-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
cache/
output/
npm-debug.log
.DS_Store
*.log

View File

@@ -0,0 +1,31 @@
FROM node:20-alpine
# Install dependencies for better-sqlite3 if needed later
RUN apk add --no-cache python3 make g++
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (npm install instead of ci for flexibility)
RUN npm install --omit=dev
# Copy application files
COPY . .
# Create necessary directories
RUN mkdir -p /app/cache/previews \
&& mkdir -p /app/output \
&& mkdir -p /app/fonts
# Expose port
EXPOSE 4000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start application
CMD ["node", "src/server.js"]

View File

@@ -0,0 +1,198 @@
# Handschrift-Variationen - Konfiguration
## Aktuelle Einstellungen
### 1. Scriptalizer Error Frequency
```
SCRIPTALIZER_ERR_FREQUENCY=0
```
- **Bedeutung:** Keine durchgestrichenen Wörter oder Tippfehler
- **Ergebnis:** Saubere, fehlerfreie Handschrift
---
### 2. Wortabstands-Variation: **15%**
**Implementierung:** `src/lib/svg-font-engine.js`
```javascript
// Leerzeichen mit 15% Variation
const baseSpace = fontSizePx * 0.4;
const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
const spacePx = baseSpace + variation;
```
**Effekt:**
- Basis-Wortabstand: 40% der Schriftgröße (≈10.4 px bei 26px Font)
- Variation: ±1.56 px (15% von 10.4px)
- Natürliche, ungleichmäßige Abstände zwischen Wörtern
---
### 3. Wort-Rotation (Schräglage): **±5%** (≈±2.5°)
**Implementierung:** `src/lib/svg-font-engine.js`
```javascript
// Rotation zwischen -2.5° und +2.5° (±0.044 radians)
const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022;
wordRotation = rotationVariation;
// Matrix-Transformation mit Rotation
const cosR = Math.cos(wordRotation);
const sinR = Math.sin(wordRotation);
const transform = `matrix(${scale * cosR},${scale * sinR},${-scale * sinR},${-scale * cosR},${x},${baselineY})`;
```
**Effekt:**
- Jedes Wort bekommt eine individuelle, leichte Schräglage
- Rotation: -2.5° bis +2.5° (±0.044 Radians)
- Macht die Handschrift natürlicher und lebendiger
- Jedes Wort ist leicht unterschiedlich geneigt
---
## Variation-Pattern
### Sinuswellen-Basis
Beide Variationen verwenden Sinuswellen für natürliche, nicht-gleichförmige Muster:
```javascript
Math.sin(wordIndex * 2.5) // Wortabstand (langsame Oszillation)
Math.sin(wordIndex * 3.7) // Rotation (schnellere Oszillation)
```
**Vorteil:**
- Keine zufälligen Sprünge
- Sanfte, natürliche Übergänge
- Reproduzierbar (gleicher Text → gleiche Variation)
- Kein Seed-Management nötig
---
## Visuelle Beispiele
### Ohne Variation (alt):
```
Hallo Max Mustermann aus Berlin
```
Alle Wörter perfekt gerade, gleiche Abstände
### Mit Variation (neu):
```
Hallo Max Mustermann aus Berlin
↗ → ↘ → ↗
```
- Unterschiedliche Wortabstände (15% Variation)
- Leichte Schräglage pro Wort (±2.5°)
---
## Technische Details
### SVG Matrix-Transformation
**Original (ohne Rotation):**
```xml
<path transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)" />
```
**Neu (mit Rotation):**
```xml
<path transform="matrix(0.00845,0.00004,-0.00004,-0.00845,75.6,120.5)" />
```
Die kleinen Werte in Position 2 und 3 der Matrix erzeugen die Rotation:
- Position 2 (b): `scale * sin(rotation)` ≈ 0.00004
- Position 3 (c): `-scale * sin(rotation)` ≈ -0.00004
---
## Anpassung der Variationen
### Wortabstand ändern
**Datei:** `src/lib/svg-font-engine.js` (Zeile 187-189)
```javascript
// Aktuell: 15%
const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
// Mehr Variation (z.B. 25%):
const variation = baseSpace * 0.25 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
// Weniger Variation (z.B. 8%):
const variation = baseSpace * 0.08 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5);
```
---
### Rotation (Schräglage) ändern
**Datei:** `src/lib/svg-font-engine.js` (Zeile 200-202)
```javascript
// Aktuell: ±2.5° (0.044 radians)
const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022;
// Stärker schräg (z.B. ±5°):
const rotationVariation = 0.087 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.0435;
// Weniger schräg (z.B. ±1°):
const rotationVariation = 0.0175 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.00875;
// Keine Rotation (deaktivieren):
const rotationVariation = 0;
```
**Umrechnung Grad → Radians:**
```
Radians = Grad × (π / 180)
±1° = ±0.0175 rad
±2.5° = ±0.044 rad
±5° = ±0.087 rad
```
---
## Test-Beispiel
```bash
curl -X POST http://localhost:4000/api/preview/batch \
-H "Content-Type: application/json" \
-d '{
"sessionId": "variation-demo",
"letters": [{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Dies ist ein Test mit natürlicher Handschrift-Variation.\n\nDie Wörter haben unterschiedliche Abstände und eine leichte Schräglage.\n\nDas macht das Schriftbild authentischer!",
"placeholders": {}
}]
}'
```
---
## Performance
**Impact:**
- Minimaler Overhead durch sin/cos Berechnungen
- Pro Zeichen: ~4 zusätzliche Operationen
- Bei 2000 Zeichen: ~0.5ms zusätzliche Verarbeitungszeit
- Vernachlässigbar im Vergleich zur Scriptalizer API (1-3 Sekunden)
---
## Kompatibilität
✅ **Alle SVG-Viewer** unterstützen Matrix-Transformationen
✅ **Alle Browser** (Chrome, Firefox, Safari, Edge)
✅ **Plotter-Software** verarbeitet transformierte Pfade korrekt
✅ **Keine Änderung der Pfad-Daten** (nur Transformation)
---
**Version:** 1.1.0
**Letzte Änderung:** 2026-01-02
**Status:** Produktionsreif ✅

View File

@@ -0,0 +1,461 @@
# Skrift Backend - Handwritten Document Generator
Docker-basiertes Backend für die Generierung von handschriftlichen Dokumenten (Briefe, Postkarten, Umschläge) mit SVG-Output.
## Features
- **Preview-System**: Batch-Generierung von Vorschauen mit Caching (30 Briefe pro Batch)
- **Scriptalizer Integration**: Nutzt externe API für natürliche Handschrift-Variationen
- **SVG-Generierung**: Eigene Font-Engine für hochqualitative SVG-Ausgabe
- **Multi-Format Support**: A4, A6 (Hoch-/Querformat), C6, DIN Lang Umschläge
- **Platzhalter-System**: Automatische Ersetzung von `[[Platzhalter]]` mit CSV-Export
- **Rate Limiting**: Schutz vor API-Spam (konfigurierbar)
- **Docker-Ready**: Vollständig containerisiert mit docker-compose
## Quick Start
### 1. Konfiguration
```bash
cp .env.example .env
# .env bearbeiten und Scriptalizer License Key eintragen
```
### 2. Lokal testen (ohne Docker)
```bash
npm install
npm start
```
Server läuft auf: `http://localhost:4000`
### 3. Mit Docker deployen
```bash
docker-compose up -d
```
## API-Endpunkte
### Health Check
```
GET /health
```
**Response:**
```json
{
"status": "healthy",
"timestamp": "2026-01-15T12:00:00Z",
"uptime": 12345,
"scriptalizer": "configured",
"storage": {
"cache": true,
"output": true
}
}
```
---
### Preview Batch Generierung
```
POST /api/preview/batch
```
**Request Body:**
```json
{
"sessionId": "uuid-abc-123",
"batchIndex": 0,
"forceRegenerate": false,
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"text": "Hallo [[Vorname]], dein Code ist [[Gutscheincode]]...",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin",
"Gutscheincode": "SAVE20"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"type": "recipient",
"data": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
}
]
}
}
```
**Response:**
```json
{
"sessionId": "uuid-abc-123",
"files": [
{
"type": "letter",
"index": 0,
"url": "/api/preview/uuid-abc-123/letter_000.svg"
},
{
"type": "envelope",
"index": 0,
"url": "/api/preview/uuid-abc-123/envelope_000.svg"
}
],
"csvUrl": "/api/preview/uuid-abc-123/platzhalter.csv",
"expiresAt": "2026-01-15T14:00:00Z"
}
```
**Rate Limit:** 2 Requests/Minute pro sessionId
---
### Preview-Datei abrufen
```
GET /api/preview/:sessionId/:filename
```
**Beispiel:**
```
GET /api/preview/uuid-abc-123/letter_000.svg
```
**Response:** SVG-Datei (Content-Type: image/svg+xml)
---
### Bestellung finalisieren (aus Cache)
```
POST /api/order/finalize
```
**Request Body:**
```json
{
"sessionId": "uuid-abc-123",
"orderNumber": "SK-2026-01-15-001"
}
```
**Response:**
```json
{
"orderNumber": "SK-2026-01-15-001",
"outputPath": "/app/output/SK-2026-01-15-001",
"files": {
"letters": 100,
"envelopes": 100,
"csv": "platzhalter.csv"
},
"timestamp": "2026-01-15T12:30:00Z"
}
```
---
### Bestellung neu generieren (ohne Cache)
```
POST /api/order/generate
```
**Request Body:**
```json
{
"orderNumber": "SK-2026-01-15-002",
"config": {
"font": "tilda",
"letters": [...],
"envelopes": [...]
}
}
```
**Response:** Gleich wie `/api/order/finalize`
---
## Formate
### Schriftstücke (Letters)
- `a4` - A4 Hochformat (210 × 297 mm)
- `a6p` - A6 Hochformat (105 × 148 mm)
- `a6l` - A6 Querformat (148 × 105 mm)
### Umschläge (Envelopes)
- `c6` - C6 Umschlag (162 × 114 mm)
- `din_lang` - DIN Lang Umschlag (220 × 110 mm)
### Fonts
- `tilda` - PremiumUltra79
- `alva` - PremiumUltra23
- `ellie` - PremiumUltra39
---
## Umschlag-Typen
### Empfänger-Adresse (type: "recipient")
Adresse wird **unten links** positioniert (kein Sichtfenster).
```json
{
"type": "recipient",
"data": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
}
```
### Individueller Text (type: "custom")
Text wird **mittig zentriert** positioniert. Max. 150 Zeichen.
```json
{
"type": "custom",
"data": {
"customText": "Für meine großartige Freundin Caro"
}
}
```
---
## Verzeichnisstruktur
```
/app/
├── cache/
│ └── previews/
│ └── {sessionId}/
│ ├── letter_000.svg
│ ├── envelope_000.svg
│ ├── platzhalter.csv
│ └── metadata.json
│
├── output/
│ └── {orderNumber}/
│ ├── schriftstuecke/
│ │ ├── brief_000.svg
│ │ └── ...
│ ├── umschlaege/
│ │ ├── umschlag_000.svg
│ │ └── ...
│ ├── platzhalter.csv
│ └── order-metadata.json
│
└── fonts/
├── tilda.svg
├── alva.svg
└── ellie.svg
```
---
## Umgebungsvariablen
```bash
# Node Environment
NODE_ENV=production
# Scriptalizer API
SCRIPTALIZER_LICENSE_KEY=your-key-here
SCRIPTALIZER_ERR_FREQUENCY=10
# Preview System
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
# Server
PORT=4000
CORS_ORIGIN=*
```
---
## Deployment
### Auf Server (mit Docker)
```bash
# .env Datei erstellen mit production values
docker-compose up -d
# Logs ansehen
docker-compose logs -f
# Stoppen
docker-compose down
```
### Nginx Proxy Manager Setup
1. Proxy Host erstellen
2. Domain: `api.skrift.de` (oder deine Domain)
3. Forward Hostname/IP: `localhost`
4. Forward Port: `4000`
5. SSL Zertifikat über NPM erstellen
---
## Entwicklung
### Lokales Testen
```bash
npm run dev # Mit nodemon
```
### Scriptalizer Separator Test
```bash
npm run test:separator
```
### Logs
```bash
# Docker logs
docker-compose logs -f skrift-backend
# Lokale logs
# Output in console
```
---
## Integration mit N8N
N8N kann direkt auf den `/app/output/{orderNumber}/` Ordner zugreifen:
```javascript
// N8N Workflow (Beispiel)
const fs = require('fs');
const orderPath = '/var/skrift-output/SK-2026-01-15-001';
// Lese alle SVGs
const letters = fs.readdirSync(`${orderPath}/schriftstuecke`);
// Sende an Plotter
for (const file of letters) {
await sendToPlotter(`${orderPath}/schriftstuecke/${file}`);
}
```
---
## Fehlerbehandlung
### HTTP Status Codes
- `200` - Success
- `400` - Bad Request (z.B. ungültige Parameter)
- `404` - Not Found (z.B. Session nicht gefunden)
- `410` - Gone (z.B. Cache abgelaufen)
- `429` - Too Many Requests (Rate Limit)
- `500` - Internal Server Error
- `503` - Service Unavailable (z.B. Scriptalizer down)
### Typische Fehler
**Rate Limit überschritten:**
```json
{
"error": "Zu viele Vorschau-Anfragen. Bitte warten Sie.",
"retryAfter": 45,
"message": "Limit: 2 Anfragen pro Minute"
}
```
**Scriptalizer Fehler:**
```json
{
"error": "Scriptalizer request failed: timeout"
}
```
**Cache abgelaufen:**
```json
{
"error": "Preview-Session abgelaufen. Bitte neu generieren."
}
```
---
## Limits
- **Scriptalizer API**: 10.000 Calls/Tag
- **Batch Size**: 30 Briefe pro Request
- **Input Size**: 48KB pro Scriptalizer Call
- **Rate Limit**: 2 Preview-Requests/Minute
- **Cache Lifetime**: 2 Stunden
---
## Troubleshooting
### Fonts nicht gefunden
```bash
# Fonts kopieren
cp /path/to/fonts/*.svg ./fonts/
```
### Scriptalizer API Fehler
```bash
# License Key prüfen
cat .env | grep SCRIPTALIZER_LICENSE_KEY
# Test-Script ausführen
npm run test:separator
```
### Permissions Fehler
```bash
# Cache/Output Ordner Permissions
chmod -R 755 cache output
```
---
## Weitere Infos
- **Scriptalizer API**: [www.scriptalizer.co.uk](https://www.scriptalizer.co.uk)
- **Support**: Siehe Issues in Repository
---
**Version**: 1.0.0
**Last Updated**: 2026-01-01

View File

@@ -0,0 +1,331 @@
# Tilda Font API-Aufruf Beispiel
## Konfiguration
- **Error Frequency:** 0 (keine durchgestrichenen Wörter)
- **Wortabstand-Variation:** ±5% für natürlicheres Schriftbild
- **Font:** Tilda (Scriptalizer: PremiumUltra79)
---
## 1. Preview-Generierung (empfohlen)
### Endpoint
```
POST http://localhost:4000/api/preview/batch
```
### Request Headers
```
Content-Type: application/json
```
### Request Body
```json
{
"sessionId": "meine-session-12345",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team",
"placeholders": {}
}
]
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/preview/batch \
-H "Content-Type: application/json" \
-d '{
"sessionId": "meine-session-12345",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team",
"placeholders": {}
}
]
}'
```
### Response
```json
{
"sessionId": "meine-session-12345",
"files": [
{
"index": 0,
"filename": "letter_000.svg",
"url": "/api/preview/meine-session-12345/letter_000.svg"
}
],
"csvUrl": "/api/preview/meine-session-12345/placeholders.csv"
}
```
### Preview SVG abrufen
```bash
curl http://localhost:4000/api/preview/meine-session-12345/letter_000.svg -o preview.svg
```
---
## 2. Mit Platzhaltern
### Request Body
```json
{
"sessionId": "platzhalter-test",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode: [[Gutscheincode]]\nGültig bis: [[Ablaufdatum]]\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Bestellnummer": "SK-2026-001",
"Gutscheincode": "SAVE20",
"Ablaufdatum": "31.12.2026"
}
}
]
}
```
---
## 3. Mehrere Briefe (Batch)
### Request Body
```json
{
"sessionId": "batch-test",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann"
}
},
{
"index": 1,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Anna",
"Nachname": "Schmidt"
}
},
{
"index": 2,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!",
"placeholders": {
"Vorname": "Thomas",
"Nachname": "Müller"
}
}
]
}
```
**Hinweis:** Bis zu 30 Briefe pro Batch möglich (konfigurierbar)
---
## 4. Bestellung finalisieren
Nach der Vorschau kann die Bestellung finalisiert werden:
### Endpoint
```
POST http://localhost:4000/api/order/finalize
```
### Request Body
```json
{
"sessionId": "meine-session-12345",
"orderNumber": "SK-2026-01-02-001"
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/order/finalize \
-H "Content-Type: application/json" \
-d '{
"sessionId": "meine-session-12345",
"orderNumber": "SK-2026-01-02-001"
}'
```
### Response
```json
{
"orderNumber": "SK-2026-01-02-001",
"outputPath": "/app/output/SK-2026-01-02-001",
"files": [
"letter_000.svg",
"placeholders.csv"
],
"timestamp": "2026-01-02T09:11:00.000Z"
}
```
---
## 5. Direkte Bestellungs-Generierung (ohne Preview)
### Endpoint
```
POST http://localhost:4000/api/order/generate
```
### Request Body
```json
{
"orderNumber": "SK-2026-01-02-002",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {}
}
]
}
```
### cURL Beispiel
```bash
curl -X POST http://localhost:4000/api/order/generate \
-H "Content-Type: application/json" \
-d '{
"orderNumber": "SK-2026-01-02-002",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {}
}
]
}'
```
---
## Verfügbare Formate
- **a4**: A4 Hochformat (210 × 297 mm)
- **a6p**: A6 Hochformat (105 × 148 mm)
- **a6l**: A6 Querformat (148 × 105 mm)
---
## Verfügbare Fonts
- **tilda**: Scriptalizer PremiumUltra79
- **alva**: Scriptalizer PremiumUltra23
- **ellie**: Scriptalizer PremiumUltra39
---
## Features
✅ **Keine Fehler:** Error Frequency = 0 (keine durchgestrichenen Wörter)
✅ **Wortabstand-Variation:** ±5% natürliche Variation für realistischeres Schriftbild
✅ **Platzhalter:** Automatische Ersetzung von [[Platzhalter]]
✅ **CSV-Export:** Automatische Generierung der Platzhalter-Tabelle
✅ **Batch-Verarbeitung:** Bis zu 30 Briefe pro Request
✅ **Preview-Caching:** 2 Stunden Cache für schnellere Finalisierung
✅ **Rate Limiting:** 2 Requests/Minute Schutz
---
## Output
Die generierten SVG-Dateien befinden sich in:
- **Preview:** `/app/cache/previews/{sessionId}/`
- **Finale Bestellung:** `/app/output/{orderNumber}/`
Struktur:
```
output/SK-2026-01-02-001/
├── letter_000.svg
├── letter_001.svg
├── placeholders.csv
└── order-metadata.json
```
---
## Beispiel Output
**letter_000.svg:**
```xml
<svg xmlns="http://www.w3.org/2000/svg"
width="210mm"
height="297mm"
viewBox="0 0 793.8 1122.66">
<rect x="0" y="0" width="793.8" height="1122.66" fill="#FFFFFF" />
<path d="M1549 1857q-55 73 -124.5 125..."
transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round" />
<!-- ~2000 weitere Pfade für handgeschriebenen Text -->
</svg>
```
Dateigröße: ~100-120 KB pro A4-Seite
---
## WordPress Integration Beispiel
```javascript
// Im WordPress Plugin
const response = await fetch('https://api.skrift.de/api/preview/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: generateUUID(),
letters: [
{
index: 0,
format: 'a4',
font: 'tilda',
text: brieftext,
placeholders: platzhalterDaten
}
]
})
});
const data = await response.json();
// Zeige Preview: data.files[0].url
```
---
**Stand:** 2026-01-02
**Version:** 1.0.0

View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
skrift-backend:
build: .
container_name: skrift-backend
restart: unless-stopped
ports:
- "4000:4000"
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=4000
- SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY}
- SCRIPTALIZER_ERR_FREQUENCY=${SCRIPTALIZER_ERR_FREQUENCY:-10}
- BATCH_SIZE=${BATCH_SIZE:-30}
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
volumes:
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
- skrift-cache:/app/cache # Preview cache (temporary)
- /var/skrift-output:/app/output # Output files for N8N
networks:
- skrift-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
volumes:
skrift-cache:
driver: local
networks:
skrift-network:
driver: bridge

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

1624
Docker/skrift-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "skrift-backend",
"version": "1.0.0",
"description": "Backend for Skrift handwritten document configurator",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"keywords": [
"skrift",
"handwriting",
"svg",
"scriptalizer"
],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.0",
"multer": "^2.0.2",
"uuid": "^9.0.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@@ -0,0 +1,541 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const config = require('../../config');
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator');
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
const { TAB_MARKER } = require('../../lib/svg-font-engine');
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*/
async function finalizeOrder(req, res, next) {
try {
const { sessionId, orderNumber } = req.body;
// Validation
if (!sessionId || !orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'sessionId and orderNumber are required'
});
}
console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`);
// Check if session cache exists
const sessionDir = path.join(config.paths.previews, sessionId);
try {
await fs.access(sessionDir);
} catch {
return res.status(404).json({
error: 'Session not found',
message: `Preview cache not found for session: ${sessionId}. Please generate previews first.`
});
}
// Check cache expiration
const metadataPath = path.join(sessionDir, '.metadata.json');
try {
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
const expiresAt = new Date(metadata.expiresAt);
if (new Date() > expiresAt) {
return res.status(410).json({
error: 'Cache expired',
message: 'Preview cache has expired. Please regenerate previews.',
expiredAt: metadata.expiresAt
});
}
} catch (err) {
console.warn(`[Order] Metadata not found for session: ${sessionId}`);
}
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Copy files from cache to output, separating letters and envelopes
const files = await fs.readdir(sessionDir);
const copiedLetters = [];
const copiedEnvelopes = [];
const copiedOther = [];
for (const file of files) {
// Skip metadata file
if (file === '.metadata.json') {
continue;
}
const sourcePath = path.join(sessionDir, file);
// Determine destination based on file type
if (file.startsWith('envelope_')) {
// Umschläge in Unterordner 'umschlaege'
const destPath = path.join(envelopesDir, file);
await fs.copyFile(sourcePath, destPath);
copiedEnvelopes.push(file);
} else if (file.startsWith('letter_')) {
// Briefe im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedLetters.push(file);
} else {
// Andere Dateien (CSV, etc.) im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedOther.push(file);
}
}
// Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
sessionId,
finalizedAt: new Date().toISOString(),
letterCount: copiedLetters.length,
envelopeCount: copiedEnvelopes.length,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length;
console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
totalFiles,
envelopesGenerated: 0,
timestamp: orderMetadata.finalizedAt
});
} catch (err) {
console.error('[Order] Error finalizing order:', err);
next(err);
}
}
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Letters array is required and must not be empty'
});
}
console.log(`[Order] Generating order: ${orderNumber}`);
console.log(`[Order] Total documents: ${letters.length}`);
// Debug: Zeige welche Dokument-Typen ankommen
const letterCount = letters.filter(l => l.type !== 'envelope').length;
const envelopeCount = letters.filter(l => l.type === 'envelope').length;
console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`);
console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType })));
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || '';
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === 'object') {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex,
processedText: text,
signatures: sigPlaceholders,
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient',
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
// Step 2: Group letters by font for batch scriptalization
const lettersByFont = {};
processedLetters.forEach(letter => {
const font = letter.font;
if (!lettersByFont[font]) {
lettersByFont[font] = [];
}
lettersByFont[font].push(letter);
});
// Step 3: Scriptalize texts by font
const scriptalizedMap = new Map();
for (const [font, fontLetters] of Object.entries(lettersByFont)) {
// Tabs (\t) und Signatur-Marker ([[signaturN]]) vor Scriptalisierung extrahieren,
// danach mit TAB_MARKER bzw. [[signaturN]] restaurieren.
const scriptalizerInputs = [];
const splitMaps = [];
for (const letter of fontLetters) {
const raw = letter.processedText;
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ loopIndex: letter.loopIndex, startIdx, segments, markers, hasContent, fallback: raw });
}
console.log(`[Order] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`);
try {
const scriptalizedAll = await scriptalizeBatch(scriptalizerInputs, font, config.scriptalizer.errFrequency);
for (const { loopIndex, startIdx, segments, markers, hasContent, fallback } of splitMaps) {
const nonEmptyCount = hasContent.filter(Boolean).length;
if (!nonEmptyCount && !markers.length) { scriptalizedMap.set(loopIndex, fallback); continue; }
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let reconstructed = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
reconstructed += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
reconstructed += (segs[i + 1] || '');
}
scriptalizedMap.set(loopIndex, reconstructed);
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
return res.status(500).json({
error: 'Scriptalization failed',
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
});
}
}
// Step 4: Generate SVG files - separate letters and envelopes
const generatedLetters = [];
const generatedEnvelopes = [];
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
// Verwende loopIndex für scriptalizedMap Zuordnung
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let svgResult;
try {
if (letter.type === 'envelope') {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText,
letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
);
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: 'SVG generation failed',
message: `Failed to generate SVG for letter ${i}: ${err.message}`
});
}
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
if (letter.type === 'envelope') {
const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(envelopesDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedEnvelopes.push(filename);
} else {
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedLetters.push(filename);
}
}
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
// Step 5: Generate all CSV files if placeholders are present
const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0);
const csvFiles = [];
if (hasPlaceholders) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = 'brief_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = 'empfaenger.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = 'umschlag_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated umschlag_platzhalter.csv`);
}
} catch (err) {
console.error('[Order] CSV generation failed:', err.message);
// Don't fail the request, CSV is optional
}
}
// Step 6: Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
letterCount: generatedLetters.length,
envelopeCount: generatedEnvelopes.length,
letters: generatedLetters,
envelopes: generatedEnvelopes,
fonts: Object.keys(lettersByFont),
hasPlaceholders,
csvFiles,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
console.log(`[Order] Order ${orderNumber} generated successfully`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
envelopes: generatedEnvelopes,
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
});
} catch (err) {
console.error('[Order] Error generating order:', err);
next(err);
}
}
/**
* POST /api/order/motif
* Upload a motif image for an order
*/
async function uploadMotif(req, res, next) {
try {
const { orderNumber } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
console.log(`[Order] Uploading motif for order: ${orderNumber}`);
console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`);
// Create output directory if it doesn't exist
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
// Determine file extension
const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png';
const filename = `motif${originalExt}`;
const filepath = path.join(outputDir, filename);
// Write file to disk
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] Motif saved to: ${filepath}`);
// Response
res.status(200).json({
success: true,
orderNumber,
filename,
path: filepath,
size: req.file.size,
mimetype: req.file.mimetype
});
} catch (err) {
console.error('[Order] Error uploading motif:', err);
next(err);
}
}
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adress-/Platzhalter-Excel, Briefdokument) im Auftragsordner ab,
* damit sie zusammen mit den generierten Umschlägen vorliegt.
*/
async function uploadOrderFile(req, res, next) {
try {
const { orderNumber, fileType } = req.body;
if (!orderNumber || !fileType) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber and fileType are required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
const outputDir = path.join(config.paths.output, orderNumber, 'dateien');
await fs.mkdir(outputDir, { recursive: true });
const ext = path.extname(req.file.originalname) || '';
const filename = `${fileType}${ext}`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] File saved for order ${orderNumber}: ${filename}`);
res.status(200).json({
success: true,
orderNumber,
fileType,
filename,
path: filepath,
size: req.file.size,
});
} catch (err) {
console.error('[Order] Error saving order file:', err);
next(err);
}
}
/**
* POST /api/order/register
* Speichert Metadaten für einen Auftrag ohne SVG-Generierung (z.B. gedruckte Briefe)
*/
async function registerOrder(req, res, next) {
try {
const { orderNumber, metadata } = req.body;
if (!orderNumber) {
return res.status(400).json({ error: 'Invalid request', message: 'orderNumber is required' });
}
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
const orderMetadata = {
orderNumber,
registeredAt: new Date().toISOString(),
...(metadata || {}),
};
await fs.writeFile(
path.join(outputDir, 'order-metadata.json'),
JSON.stringify(orderMetadata, null, 2),
'utf8'
);
console.log(`[Order] Registered order ${orderNumber}`);
res.status(200).json({ orderNumber, registeredAt: orderMetadata.registeredAt });
} catch (err) {
console.error('[Order] Error registering order:', err);
next(err);
}
}
module.exports = {
finalizeOrder,
generateOrder,
uploadMotif,
uploadOrderFile,
registerOrder,
};

View File

@@ -0,0 +1,362 @@
const fs = require("fs").promises;
const fsSync = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const config = require("../../config");
const { scriptalizeBatch } = require("../../services/scriptalizer-service");
const {
generateLetterSVG,
generateEnvelopeSVG,
} = require("../../lib/svg-generator");
const {
replacePlaceholders,
generateAllCSVs,
} = require("../../services/placeholder-service");
const { TAB_MARKER } = require("../../lib/svg-font-engine");
/**
* POST /api/preview/batch
* Generate preview batch with rate limiting
*/
async function generateBatch(req, res, next) {
try {
const { sessionId, letters } = req.body;
// Validation
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: "Invalid request",
message: "Letters array is required and must not be empty",
});
}
// KEIN Limit mehr - Frontend sendet ALLE Dokumente auf einmal
// Backend teilt sie intern in 25er Batches für Scriptalizer auf
console.log(`[Preview] Received ${letters.length} letters (no limit)`);
// Optional: Warn if extremely large
if (letters.length > 1000) {
console.warn(`[Preview] Large batch detected: ${letters.length} letters`);
}
// Generate or validate sessionId
const finalSessionId = sessionId || uuidv4();
console.log(
`[Preview] Starting batch generation for session: ${finalSessionId}`
);
console.log(`[Preview] Batch size: ${letters.length} letters`);
// Create session cache directory
const sessionDir = path.join(config.paths.previews, finalSessionId);
await fs.mkdir(sessionDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
console.log(
"[Preview] Received indices from frontend:",
letters.map((l) => l.index)
);
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || "";
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === "object") {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
processedText: text,
signatures: sigPlaceholders,
format: letter.format || "a4",
font: letter.font || "tilda",
type: letter.type || "letter",
envelopeType: letter.envelopeType || "recipient",
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
console.log(
"[Preview] Final indices being used for files:",
processedLetters.map((l) => l.index)
);
// Step 2: Batch scriptalize texts.
// Tabs (\t) und Signatur-Marker ([[signaturN]]) werden vor der Scriptalisierung
// extrahiert, anschliessend wieder restauriert (TAB_MARKER für Tabs, {{signaturN}} unverändert).
const font = processedLetters[0]?.font || "tilda";
const scriptalizerInputs = [];
const splitMaps = []; // pro Brief: { segments[], markers[] }
for (const letter of processedLetters) {
const raw = letter.processedText;
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden (API könnte sie ablehnen)
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ startIdx, segments, markers, hasContent });
}
console.log(
`[Preview] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`
);
let scriptalizedAll;
try {
scriptalizedAll = await scriptalizeBatch(
scriptalizerInputs,
font,
config.scriptalizer.errFrequency
);
} catch (err) {
console.error("[Preview] Scriptalization failed:", err.message);
return res.status(500).json({
error: "Scriptalization failed",
message: err.message,
});
}
// Segmente und Marker wieder zusammensetzen; \t → TAB_MARKER, [[signaturN]] unverändert
// Leere Segmente werden als '' wiederhergestellt (wurden nicht scriptalisiert)
const scriptalizedTexts = splitMaps.map(({ startIdx, segments, markers, hasContent }) => {
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let result = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
result += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
result += (segs[i + 1] || '');
}
return result;
});
if (scriptalizedTexts.length !== processedLetters.length) {
console.warn(
`[Preview] Scriptalization mismatch: expected ${processedLetters.length}, got ${scriptalizedTexts.length}`
);
}
// Step 3: Generate SVG files
const files = [];
let hasOverflow = false;
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
let svgResult;
try {
if (letter.type === "envelope") {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
);
} else {
// Briefe: Zeileninfo mit zurückgeben
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font,
realisticHandwriting: letter.realisticHandwriting ?? false,
signatures: letter.signatures || {},
});
}
} catch (err) {
console.error(
`[Preview] SVG generation failed for letter ${i}:`,
err.message
);
return res.status(500).json({
error: "SVG generation failed",
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
});
}
// Track overflow
if (svgResult.overflow) {
hasOverflow = true;
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
}
// Save SVG file - verwende den absoluten Index aus letter
// Unterscheide zwischen Briefen und Umschlägen im Dateinamen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const prefix = letter.type === 'envelope' ? 'envelope' : 'letter';
const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`;
const filepath = path.join(sessionDir, filename);
await fs.writeFile(filepath, svgResult.svg, "utf8");
// Empfänger-Info für bessere Fehlermeldung
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
files.push({
index: absoluteIndex,
filename,
url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount,
lineLimit: svgResult.lineLimit,
overflow: svgResult.overflow,
recipientName: letter.type !== 'envelope' ? recipientName : null,
});
}
console.log(`[Preview] Generated ${files.length} SVG files`);
// Step 4: Generate all CSV files (placeholders, recipients, envelope placeholders, free addresses)
const csvUrls = {};
const hasPlaceholders = processedLetters.some((l) => l.placeholders && Object.keys(l.placeholders).length > 0);
const hasFreeAddresses = processedLetters.some((l) => l.type === 'envelope' && l.envelopeType === 'free');
if (hasPlaceholders || hasFreeAddresses) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = "brief_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.placeholders, "utf8");
csvUrls.placeholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = "empfaenger.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.recipients, "utf8");
csvUrls.recipients = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = "umschlag_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.envelopePlaceholders, "utf8");
csvUrls.envelopePlaceholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated umschlag_platzhalter.csv`);
}
// Freie Adressen CSV (für free-Modus)
if (csvs.freeAddresses) {
const filename = "freie_adressen.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.freeAddresses, "utf8");
csvUrls.freeAddresses = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated freie_adressen.csv`);
}
} catch (err) {
console.error("[Preview] CSV generation failed:", err.message);
// Don't fail the request, CSV is optional
}
}
// Step 5: Save session metadata (no expiration)
const metadataPath = path.join(sessionDir, ".metadata.json");
const metadata = {
sessionId: finalSessionId,
createdAt: new Date().toISOString(),
letterCount: files.length,
font,
hasPlaceholders,
};
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
console.log(
`[Preview] Batch generation complete for session: ${finalSessionId}`
);
// Response
res.status(200).json({
sessionId: finalSessionId,
files,
csvUrls,
hasOverflow,
overflowFiles: files.filter(f => f.overflow),
});
} catch (err) {
console.error("[Preview] Unexpected error:", err);
next(err);
}
}
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview files
*/
async function servePreview(req, res, next) {
try {
const { sessionId, filename } = req.params;
// Validate inputs
if (!sessionId || !filename) {
return res.status(400).json({
error: "Invalid request",
message: "sessionId and filename are required",
});
}
// Prevent directory traversal
const sanitizedFilename = path.basename(filename);
const filePath = path.join(
config.paths.previews,
sessionId,
sanitizedFilename
);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return res.status(404).json({
error: "File not found",
message: `Preview file not found: ${sanitizedFilename}`,
});
}
// No cache expiration check - files are served until manually deleted
// Determine content type
const ext = path.extname(sanitizedFilename).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".svg") {
contentType = "image/svg+xml";
} else if (ext === ".csv") {
contentType = "text/csv";
}
// Serve file - NO CACHING
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
const fileContent = await fs.readFile(filePath);
res.send(fileContent);
} catch (err) {
console.error("[Preview] Error serving file:", err);
next(err);
}
}
// No automatic cache cleanup - files are kept until manually deleted
module.exports = {
generateBatch,
servePreview,
};

View File

@@ -0,0 +1,72 @@
/**
* Authentication Middleware
* Validiert API-Token in Request-Headers
*/
const config = require('../../config');
/**
* Middleware: API-Token validieren
*/
function authenticateApiToken(req, res, next) {
// API-Token aus Umgebungsvariablen
const validToken = config.auth.apiToken;
// Wenn kein Token konfiguriert ist, alle Requests erlauben (Development)
if (!validToken) {
console.warn('[Auth] WARNING: No API token configured - authentication disabled!');
return next();
}
// Token aus verschiedenen Quellen prüfen
const token =
req.headers['x-api-token'] || // Custom Header
req.headers['authorization']?.replace('Bearer ', '') || // Bearer Token
req.query.api_token; // Query Parameter (nur für Tests)
// Kein Token vorhanden
if (!token) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API token required. Please provide X-API-Token header or Authorization: Bearer <token>',
});
}
// Token validieren
if (token !== validToken) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid API token',
});
}
// Token ist gültig
next();
}
/**
* Optionale Authentifizierung - Warnung aber kein Fehler
*/
function optionalAuth(req, res, next) {
const validToken = config.auth.apiToken;
if (!validToken) {
return next();
}
const token =
req.headers['x-api-token'] ||
req.headers['authorization']?.replace('Bearer ', '') ||
req.query.api_token;
if (!token || token !== validToken) {
console.warn('[Auth] Unauthenticated request to', req.path);
}
next();
}
module.exports = {
authenticateApiToken,
optionalAuth,
};

View File

@@ -0,0 +1,13 @@
module.exports = (err, req, res, next) => {
console.error('Error occurred:', err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};

View File

@@ -0,0 +1,53 @@
const config = require('../../config');
// Simple in-memory rate limiter
const rateLimitStore = new Map();
// Cleanup old entries every minute
setInterval(() => {
const now = Date.now();
const oneMinute = 60 * 1000;
for (const [key, data] of rateLimitStore.entries()) {
if (now - data.windowStart > oneMinute) {
rateLimitStore.delete(key);
}
}
}, 60 * 1000);
module.exports = (req, res, next) => {
const sessionId = req.body.sessionId || req.params.sessionId || 'anonymous';
const now = Date.now();
const oneMinute = 60 * 1000;
let rateData = rateLimitStore.get(sessionId);
if (!rateData || now - rateData.windowStart > oneMinute) {
// New window
rateData = {
windowStart: now,
requests: []
};
rateLimitStore.set(sessionId, rateData);
}
// Remove requests older than 1 minute
rateData.requests = rateData.requests.filter(timestamp => now - timestamp < oneMinute);
// Check limit
if (rateData.requests.length >= config.preview.rateLimitPerMinute) {
const oldestRequest = Math.min(...rateData.requests);
const retryAfter = Math.ceil((oneMinute - (now - oldestRequest)) / 1000);
return res.status(429).json({
error: 'Zu viele Vorschau-Anfragen. Bitte warten Sie.',
retryAfter,
message: `Limit: ${config.preview.rateLimitPerMinute} Anfragen pro Minute`
});
}
// Add current request
rateData.requests.push(now);
next();
};

View File

@@ -0,0 +1,14 @@
module.exports = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const timestamp = new Date().toISOString();
console.log(
`[${timestamp}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`
);
});
next();
};

View File

@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const config = require('../../config');
router.get('/', (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
scriptalizer: config.scriptalizer.licenseKey ? 'configured' : 'missing',
storage: {
cache: fs.existsSync(config.paths.cache) && isWritable(config.paths.cache),
output: fs.existsSync(config.paths.output) && isWritable(config.paths.output)
}
};
const allHealthy = health.scriptalizer === 'configured' &&
health.storage.cache &&
health.storage.output;
res.status(allHealthy ? 200 : 503).json(health);
});
function isWritable(path) {
try {
fs.accessSync(path, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
module.exports = router;

View File

@@ -0,0 +1,135 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
// 10 MB reichten für hochauflösende bzw. gescannte Brief-PDFs nicht aus – multer
// verwarf die Datei still, bevor der Controller lief.
const MAX_UPLOAD_BYTES = 120 * 1024 * 1024; // 120MB
// Multer konfigurieren für Datei-Uploads (temporär im Speicher)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
// Erlaubte Dateitypen
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'application/pdf'];
const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur PNG, JPG, WEBP, SVG und PDF Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*
* Request body:
* {
* sessionId: string,
* orderNumber: string
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/finalize', authenticateApiToken, orderController.finalizeOrder);
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*
* Request body:
* {
* orderNumber: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/generate', authenticateApiToken, orderController.generateOrder);
/**
* POST /api/order/motif
* Upload a motif image for an order
*
* Request: multipart/form-data
* - motif: file (PNG, JPG, WEBP, SVG, PDF)
* - orderNumber: string
*
* Response:
* {
* success: boolean,
* filename: string,
* path: string
* }
*/
router.post('/motif', authenticateApiToken, upload.single('motif'), orderController.uploadMotif);
// Multer für Auftragsdateien (Adress-/Platzhalter-Excel, Briefdokument)
const uploadOrderFile = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
const allowedExts = ['.xlsx', '.xls', '.csv', '.pdf', '.doc', '.docx'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur XLSX, XLS, CSV, PDF, DOC oder DOCX Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adresse, Platzhalter, Brief, Brief-Excel) im Auftragsordner
*
* Request: multipart/form-data
* - file: file (XLSX, XLS, CSV, PDF, DOC, DOCX)
* - orderNumber: string
* - fileType: 'adresse' | 'platzhalter' | 'brief' | 'brief_excel'
*/
router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orderController.uploadOrderFile);
/**
* POST /api/order/register
* Metadaten-Auftrag ohne SVG-Generierung (gedruckte Briefe)
*/
router.post('/register', authenticateApiToken, orderController.registerOrder);
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const previewController = require('../controllers/preview-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/preview/batch
* Generate preview batch (no rate limiting, no batch size limit)
* Backend automatically splits into 25-letter batches for Scriptalizer API
*
* Request body:
* {
* sessionId: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* sessionId: string,
* files: [
* {
* index: number,
* filename: string,
* url: string
* }
* ],
* csvUrl?: string
* }
*/
router.post('/batch', authenticateApiToken, previewController.generateBatch);
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview SVG files
*/
router.get('/:sessionId/:filename', previewController.servePreview);
module.exports = router;

View File

@@ -0,0 +1,53 @@
require('dotenv').config();
const path = require('path');
const isProduction = process.env.NODE_ENV === 'production';
module.exports = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 4000,
scriptalizer: {
licenseKey: process.env.SCRIPTALIZER_LICENSE_KEY,
errFrequency: parseInt(process.env.SCRIPTALIZER_ERR_FREQUENCY, 10) || 0,
endpoint: 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize',
fontMap: {
tilda: 'PremiumUltra79',
alva: 'PremiumUltra23',
ellie: 'PremiumUltra39'
},
separator: '|||', // Triple pipe separator (tested and working)
maxInputSize: 48000 // 48KB limit
},
preview: {
// No batch size limit - frontend can send any number of letters
// Backend splits into 25-letter batches for Scriptalizer API internally
scriptalizerBatchSize: 25
// No cache lifetime - files are kept until manually cleaned
// No rate limiting
},
paths: {
cache: isProduction ? '/app/cache' : path.join(__dirname, '../../cache'),
previews: isProduction ? '/app/cache/previews' : path.join(__dirname, '../../cache/previews'),
output: isProduction ? '/app/output' : path.join(__dirname, '../../output'),
fonts: isProduction ? '/app/fonts' : path.join(__dirname, '../../fonts')
},
cors: {
origin: process.env.CORS_ORIGIN || '*',
credentials: true
},
auth: {
apiToken: process.env.API_TOKEN || null
},
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || '',
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
// 'sandbox' oder 'live'
environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox'
}
};

View File

@@ -0,0 +1,124 @@
const PX_PER_MM = 3.78; // 96dpi
const FONT_SIZE_PX = 26;
// Maximale Zeilenanzahl pro Format (für Textvalidierung)
const LINE_LIMITS = {
a4: 27,
a6p: 14,
a6l: 11,
};
// Layout Konfiguration pro Format + Orientation
const FORMAT_LAYOUTS = {
// A4 Hochformat
a4: {
marginTopMm: 25,
marginLeftMm: 20,
lineHeightFactor: 1.35,
},
// A6 Hochformat
a6p: {
marginTopMm: 8,
marginLeftMm: 8,
lineHeightFactor: 1.3,
},
// A6 Querformat (landscape)
a6l: {
marginTopMm: 8,
marginLeftMm: 8,
marginRightMm: 14,
lineHeightFactor: 1.2,
},
// DIN Lang Kuvert (Querformat)
din_lang: {
marginTopMm: 50,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
// C6 Kuvert (Querformat)
c6: {
marginTopMm: 60,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
};
// Seiten-Dimensionen
const PAGE_FORMATS = {
a4: { widthMm: 210, heightMm: 297 },
a6p: { widthMm: 105, heightMm: 148 },
a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht
din_lang: { widthMm: 220, heightMm: 110 },
c6: { widthMm: 162, heightMm: 114 },
};
// mm in Pixel umrechnen
function mmToPx(mm) {
return mm * PX_PER_MM;
}
// Layout für Format holen
function getLayoutForFormat(format) {
const normalizedFormat = String(format).toLowerCase();
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
if (!layoutConfig) {
// Fallback auf a4
return {
marginTopPx: mmToPx(FORMAT_LAYOUTS.a4.marginTopMm),
marginLeftPx: mmToPx(FORMAT_LAYOUTS.a4.marginLeftMm),
lineHeightPx: FONT_SIZE_PX * FORMAT_LAYOUTS.a4.lineHeightFactor,
};
}
return {
marginTopPx: mmToPx(layoutConfig.marginTopMm),
marginLeftPx: mmToPx(layoutConfig.marginLeftMm),
marginRightPx: layoutConfig.marginRightMm ? mmToPx(layoutConfig.marginRightMm) : null,
lineHeightPx: FONT_SIZE_PX * layoutConfig.lineHeightFactor,
};
}
// Seiten-Dimensionen holen
function getPageDimensions(format) {
const normalizedFormat = String(format).toLowerCase();
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
return {
widthMm: dimensions.widthMm,
heightMm: dimensions.heightMm,
widthPx: mmToPx(dimensions.widthMm),
heightPx: mmToPx(dimensions.heightMm),
};
}
// XML Escaping
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Zeilenlimit für Format holen
function getLineLimit(format) {
const normalizedFormat = String(format).toLowerCase();
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
}
module.exports = {
PX_PER_MM,
FONT_SIZE_PX,
FORMAT_LAYOUTS,
PAGE_FORMATS,
LINE_LIMITS,
mmToPx,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
};

View File

@@ -0,0 +1,283 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
// Cache je Fontdatei
const FONT_CACHE = {};
// Attribut aus einem Tag lesen (mit Wortgrenze, um z.B. 'd' vs 'id' zu unterscheiden)
function getAttr(tag, name) {
const re = new RegExp(`\\b${name}="([^"]*)"`);
const m = tag.match(re);
return m ? m[1] : null;
}
// XML-Entities im unicode-Attribut dekodieren (z.B. &lt; → <, &amp; → &, &#x22; → ")
function decodeXmlEntities(str) {
return str
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#([0-9]+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// SVG-Font parsen und Metriken plus Glyphen extrahieren
function parseSvgFont(fontFileName) {
if (FONT_CACHE[fontFileName]) return FONT_CACHE[fontFileName];
const fontPath = path.join(config.paths.fonts, fontFileName);
const svg = fs.readFileSync(fontPath, 'utf8');
const fontFaceMatch = svg.match(/<font-face[\s\S]*?>/);
if (!fontFaceMatch) {
throw new Error(`Kein <font-face> in ${fontFileName} gefunden`);
}
const fontFaceTag = fontFaceMatch[0];
const unitsPerEm = parseFloat(getAttr(fontFaceTag, 'units-per-em') || '1000');
const ascent = parseFloat(getAttr(fontFaceTag, 'ascent') || '0');
const descent = parseFloat(getAttr(fontFaceTag, 'descent') || '0');
const glyphRegex = /<glyph[\s\S]*?\/>/g;
const glyphs = {};
let m;
while ((m = glyphRegex.exec(svg)) !== null) {
const tag = m[0];
const unicode = decodeXmlEntities(getAttr(tag, 'unicode') || '');
const d = getAttr(tag, 'd');
const adv = parseFloat(getAttr(tag, 'horiz-adv-x') || '0');
if (!unicode || !d) continue;
glyphs[unicode] = {
d,
adv,
};
}
const fontData = {
unitsPerEm,
ascent,
descent,
glyphs,
};
FONT_CACHE[fontFileName] = fontData;
return fontData;
}
// Zeichen, das nach der Scriptalisierung als Tabulator-Marker eingebettet wird (Unicode PUA)
const TAB_MARKER = '';
/**
* Breite einer Textzeile in Pixeln messen.
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
*/
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const tabStopPx = fontSizePx * 6;
let x = 0;
for (const ch of String(text || '')) {
if (ch === TAB_MARKER) {
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx;
continue;
}
const glyph = glyphs[ch];
if (!glyph) {
// Fallback für Leerzeichen / unbekannte Zeichen
const spacePx = fontSizePx * 0.4;
x += spacePx;
continue;
}
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
x += advPx;
}
return x;
}
/**
* Text nach verfügbarer Breite umbrechen.
* Garantiert: keine Zeile wird breiter als maxWidthPx.
* Unterstützt TAB_MARKER als Tabulator-Sprung innerhalb einer Zeile.
*/
function wrapTextToLinesByWidth({
text,
maxWidthPx,
fontFileName,
fontSizePx,
}) {
const paragraphs = String(text || '').split(/\r?\n/);
const allLines = [];
const measure = (t) =>
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
for (const para of paragraphs) {
// Tokens: Wörter und TAB_MARKER getrennt ermitteln.
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
const TAB_PLACEHOLDER = '';
const normalizedPara = para.split(TAB_MARKER).join(` ${TAB_PLACEHOLDER} `);
const rawWords = normalizedPara.split(/\s+/).filter(Boolean);
const tokens = rawWords.map(w => (w === TAB_PLACEHOLDER ? TAB_MARKER : w));
if (tokens.length === 0) {
allLines.push('');
continue;
}
let currentLine = '';
let afterTab = false;
for (const token of tokens) {
if (token === TAB_MARKER) {
currentLine += TAB_MARKER;
afterTab = true;
continue;
}
// Wort alleine zu breit → Zeichenweise brechen
if (measure(token) > maxWidthPx) {
if (currentLine) {
allLines.push(currentLine);
currentLine = '';
afterTab = false;
}
let buf = '';
for (const ch of token) {
const candidate = buf + ch;
if (measure(candidate) > maxWidthPx && buf) {
allLines.push(buf);
buf = ch;
} else {
buf = candidate;
}
}
if (buf) { currentLine = buf; afterTab = false; }
continue;
}
if (!currentLine) {
currentLine = token;
afterTab = false;
continue;
}
// Nach einem Tab kein Leerzeichen als Trenner einfügen
const sep = afterTab ? '' : ' ';
const candidate = `${currentLine}${sep}${token}`;
if (measure(candidate) <= maxWidthPx) {
currentLine = candidate;
afterTab = false;
} else {
allLines.push(currentLine);
currentLine = token;
afterTab = false;
}
}
if (currentLine) {
allLines.push(currentLine);
}
}
return allLines;
}
/**
* Textzeilen in Pfade mit Transform-Matrix umwandeln.
*/
function layoutTextToPaths({
lines,
fontFileName,
fontSizePx,
startX,
startY,
lineHeightPx,
realisticHandwriting = false,
}) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const resultPaths = [];
let baselineY = startY;
for (const line of lines) {
let x = startX;
let drift = 0;
const lineSlope = realisticHandwriting ? (Math.random() - 0.5) * 0.01 : 0;
const tabStopPx = fontSizePx * 6;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
// Tabulator-Marker → zur nächsten Tabstop-Position springen
if (ch === TAB_MARKER) {
const relX = x - startX;
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx;
x = startX + nextTab;
continue;
}
const glyph = glyphs[ch];
if (!glyph) {
// Leerzeichen mit leicht variierender Breite
const spacePx = fontSizePx * 0.4 * (realisticHandwriting ? (1 + (Math.random() - 0.5) * 0.08) : 1);
x += spacePx;
if (realisticHandwriting) {
drift += (Math.random() - 0.5) * 0.3;
drift = Math.max(-3, Math.min(3, drift));
}
continue;
}
const d = glyph.d;
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
const yJitter = realisticHandwriting ? (Math.random() - 0.5) * 2.5 : 0;
if (realisticHandwriting) {
drift += (Math.random() - 0.5) * 0.4;
drift = Math.max(-3, Math.min(3, drift));
}
const charY = baselineY + yJitter + drift + lineSlope * (x - startX);
const angleDeg = realisticHandwriting ? 5 + (Math.random() - 0.5) * 2 : 0;
const pivotX = x + advPx / 2;
const pivotY = charY - fontSizePx * 0.3;
const transform = `rotate(${angleDeg.toFixed(3)},${pivotX.toFixed(1)},${pivotY.toFixed(1)}) matrix(${scale},0,0,${-scale},${x},${charY})`;
resultPaths.push({ d, transform });
x += advPx;
}
baselineY += lineHeightPx;
}
return resultPaths;
}
module.exports = {
TAB_MARKER,
measureTextWidthPx,
wrapTextToLinesByWidth,
layoutTextToPaths
};

View File

@@ -0,0 +1,341 @@
const {
FONT_SIZE_PX,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
} = require('./page-layout');
const {
layoutTextToPaths,
wrapTextToLinesByWidth,
measureTextWidthPx
} = require('./svg-font-engine');
// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale)
const SIG_CANVAS_W = 600;
const SIG_CANVAS_H = 160;
/**
* Extrahiert Path- und Polyline-Elemente aus einem SVG-String und gibt sie als
* umgeformte <g>-Gruppe zurück, die an Position (x, y) mit Skalierung scale eingebettet ist.
*/
function buildSignatureElement(svgString, x, y, scale) {
if (!svgString || typeof svgString !== 'string') return '';
// Nur <path>-Elemente extrahieren (unsere Signatur-SVGs enthalten ausschließlich Paths)
const pathRegex = /<path\b([^/]*)\/>/gi;
const elements = [];
let m;
while ((m = pathRegex.exec(svgString)) !== null) {
// Stroke-Farbe auf Druckfarbe setzen, stroke-width relativ zur Skalierung anpassen
const attrs = m[1]
.replace(/stroke="[^"]*"/i, 'stroke="#000000"')
.replace(/stroke-width="[^"]*"/i, `stroke-width="${(2.5 / scale).toFixed(2)}"`);
elements.push(`<path${attrs}/>`);
}
if (!elements.length) return '';
return `<g transform="translate(${x.toFixed(1)}, ${y.toFixed(1)}) scale(${scale.toFixed(4)})">
${elements.join('\n ')}
</g>`;
}
// Mapping der Handschriften auf SVG-Font-Dateien
const SVG_FONT_CONFIG = {
tilda: { file: 'tilda.svg' },
alva: { file: 'alva.svg' },
ellie: { file: 'ellie.svg' },
};
function normalizeFontKey(font) {
if (!font) return 'tilda';
const f = String(font).toLowerCase();
if (['tilda', 'alva', 'ellie'].includes(f)) return f;
return 'tilda';
}
/**
* Generiert ein Schriftstück (Brief/Postkarte) als SVG
* @param {string} text - Der scriptalisierte Text
* @param {string} format - Format: a4, a6p, a6l
* @param {object} options - { font: 'tilda', alignment: 'left' }
*/
function generateLetterSVG(text, format, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const signatures = options.signatures || {};
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format);
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
// ── Signatur-Marker aus Text extrahieren ──────────────────────────────────
const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi;
const hasSigMarkers = sigMarkerRe.test(String(text || ''));
if (!hasSigMarkers) {
// ── Standard-Pfad (ohne Signaturen) ──────────────────────────────────────
const lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
const firstBaselineY = marginTopPx + FONT_SIZE_PX;
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: marginLeftPx,
startY: firstBaselineY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
const pathElements = paths
.map(p => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000" stroke-width="0.6" fill="none"
stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"/>`)
.join('\n ');
const lineLimit = getLineLimit(format);
return {
svg: `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
${pathElements}
</svg>`.trim(),
lineCount: lines.length,
lineLimit,
overflow: lines.length > lineLimit,
};
}
// ── Pfad mit Signaturen: Text in Segmente aufteilen ───────────────────────
// Jede Signatur-Zeile ist 2 Zeilen hoch; bis zu 3 Signaturen nebeneinander.
const sigHeightPx = 2 * lineHeightPx;
const sigSlotW = maxWidthPx / 3; // Breite pro Signatur-Slot
const sigScaleH = sigHeightPx / SIG_CANVAS_H;
const sigScaleW = sigSlotW / SIG_CANVAS_W;
const sigScale = Math.min(sigScaleH, sigScaleW); // gleichmäßig skalieren
const sigPaddingPx = lineHeightPx * 0.3; // kleiner Abstand ober/unterhalb
const rawText = String(text || '');
const segments = [];
sigMarkerRe.lastIndex = 0;
let lastEnd = 0;
let matchSig;
while ((matchSig = sigMarkerRe.exec(rawText)) !== null) {
if (matchSig.index > lastEnd) {
segments.push({ type: 'text', content: rawText.slice(lastEnd, matchSig.index) });
}
segments.push({ type: 'sig', key: matchSig[1].toLowerCase() });
lastEnd = matchSig.index + matchSig[0].length;
}
if (lastEnd < rawText.length) {
segments.push({ type: 'text', content: rawText.slice(lastEnd) });
}
const allSvgElements = [];
let currentY = marginTopPx + FONT_SIZE_PX;
let totalLineCount = 0;
const lineLimit = getLineLimit(format);
let si = 0;
while (si < segments.length) {
const seg = segments[si];
if (seg.type === 'text') {
const trimmed = seg.content.replace(/^\n+/, '').replace(/\n+$/, '');
if (!trimmed) { si++; continue; }
const lines = wrapTextToLinesByWidth({
text: seg.content,
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: marginLeftPx,
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
paths.forEach(p => allSvgElements.push(
`<path d="${escapeXml(p.d)}" transform="${p.transform}"
stroke="#000000" stroke-width="0.6" fill="none"
stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"/>`
));
currentY += lines.length * lineHeightPx;
totalLineCount += lines.length;
si++;
} else {
// Aufeinanderfolgende Signaturen als Zeile nebeneinander rendern
const sigRow = [];
while (si < segments.length && segments[si].type === 'sig') {
sigRow.push(segments[si]);
si++;
}
currentY += sigPaddingPx;
sigRow.forEach((sigSeg, slotIdx) => {
const svgString = signatures[sigSeg.key] || null;
if (svgString) {
const sigX = marginLeftPx + slotIdx * sigSlotW;
const sigEl = buildSignatureElement(svgString, sigX, currentY, sigScale);
if (sigEl) allSvgElements.push(sigEl);
}
});
currentY += sigHeightPx + sigPaddingPx;
totalLineCount += 2; // ganze Zeile zählt als 2 Zeilen, unabhängig von Anzahl
}
}
return {
svg: `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
${allSvgElements.join('\n ')}
</svg>`.trim(),
lineCount: totalLineCount,
lineLimit,
overflow: totalLineCount > lineLimit,
};
}
/**
* Generiert einen Umschlag als SVG
* @param {string} text - Der scriptalisierte Text
* @param {string} format - Format: c6, din_lang
* @param {string} type - Typ: 'recipient' (links), 'free' (links, freie Adresse), 'custom' (mittig)
* @param {object} options - { font: 'tilda' }
*/
function generateEnvelopeSVG(text, format, type, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format);
let lines;
let startX;
let startY;
if (type === 'custom') {
// Mittig zentriert, 70% der Umschlagsbreite
const maxWidthPx = widthPx * 0.7;
lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
// Zentrierung berechnen
const totalTextHeight = lines.length * lineHeightPx;
startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX;
// Jede Zeile einzeln zentrieren
// startX wird später pro Zeile berechnet
startX = 0; // Dummy-Wert, wird für jede Zeile überschrieben
} else {
// type === 'recipient' / 'free': Links, max. 60% der Umschlagsbreite
const maxWidthPx = Math.min(widthPx - marginLeftPx - 20, widthPx * 0.6);
lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
});
startX = marginLeftPx;
startY = marginTopPx + FONT_SIZE_PX;
}
let paths = [];
if (type === 'custom') {
// Bei custom type: Jede Zeile einzeln zentrieren
let currentY = startY;
for (const line of lines) {
const lineWidth = measureTextWidthPx({
text: line,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX
});
const centeredX = (widthPx - lineWidth) / 2;
const linePaths = layoutTextToPaths({
lines: [line],
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX: centeredX,
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
paths.push(...linePaths);
currentY += lineHeightPx;
}
} else {
// Bei recipient type: Normal links ausrichten
paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
startX,
startY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
});
}
const pathElements = paths
.map(
(p) => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>`
)
.join('\n ');
const svg = `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm"
height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF" />
${pathElements}
</svg>`;
// Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche
const overflowStartY = (type === 'custom')
? (heightPx - lines.length * lineHeightPx) / 2 + FONT_SIZE_PX
: marginTopPx + FONT_SIZE_PX;
const available = heightPx - overflowStartY - FONT_SIZE_PX * 0.5;
const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1);
const lineCount = lines.length;
return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit };
}
module.exports = {
generateLetterSVG,
generateEnvelopeSVG
};

View File

@@ -0,0 +1,53 @@
const express = require('express');
const cors = require('cors');
const config = require('./config');
const previewRoutes = require('./api/routes/preview-routes');
const orderRoutes = require('./api/routes/order-routes');
const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler');
const requestLogger = require('./api/middleware/request-logger');
const app = express();
// Middleware
app.use(cors(config.cors));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(requestLogger);
// Routes
app.use('/health', healthRoutes);
app.use('/api/preview', previewRoutes);
app.use('/api/order', orderRoutes);
// Error handling
app.use(errorHandler);
// Start server
app.listen(config.port, () => {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Skrift Backend Server ║');
console.log('╚════════════════════════════════════════════════════════════╝');
console.log(`Environment: ${config.env}`);
console.log(`Port: ${config.port}`);
console.log(`Batch Size: Unlimited (auto-split to ${config.preview.scriptalizerBatchSize} for API)`);
console.log(`Cache: Disabled`);
console.log(`Rate Limit: Disabled`);
console.log(`Scriptalizer: ${config.scriptalizer.licenseKey ? 'Configured ✓' : 'Missing ✗'}`);
console.log('════════════════════════════════════════════════════════════');
console.log(`Server running at http://localhost:${config.port}`);
console.log('════════════════════════════════════════════════════════════\n');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('\nSIGINT received, shutting down gracefully...');
process.exit(0);
});

View File

@@ -0,0 +1,283 @@
/**
* Ersetzt Platzhalter im Text
* @param {string} text - Text mit Platzhaltern wie [[Vorname]]
* @param {object} placeholders - Objekt mit Platzhalter-Werten {Vorname: 'Max', ...}
* @returns {string} - Text mit ersetzten Platzhaltern
*/
function replacePlaceholders(text, placeholders) {
if (!placeholders || typeof placeholders !== 'object') {
return text;
}
let result = text;
for (const [key, value] of Object.entries(placeholders)) {
const placeholder = `[[${key}]]`;
// Case-insensitive replacement: 'i' flag
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
result = result.replace(regex, value || '');
}
return result;
}
/**
* Escaped einen Wert für CSV
*/
function escapeCSV(value) {
const str = String(value || '');
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/**
* Generiert CSV aus Platzhalter-Daten (für Briefe)
* @param {Array} letters - Array von Letter-Objekten mit placeholders
* @returns {string} - CSV-String
*/
function generatePlaceholderCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Briefe filtern (keine Umschläge)
const letterItems = letters.filter(l => l.type !== 'envelope');
if (letterItems.length === 0) {
return '';
}
// Sammle alle unique Keys
const allKeys = new Set();
letterItems.forEach(letter => {
if (letter.placeholders) {
Object.keys(letter.placeholders).forEach(key => allKeys.add(key));
}
});
if (allKeys.size === 0) {
return '';
}
const keys = ['BriefNr', ...Array.from(allKeys).sort()];
// Header
const header = keys.join(',');
// Rows
const rows = letterItems.map((letter, index) => {
const briefNr = String(letter.index !== undefined ? letter.index : index).padStart(3, '0');
const values = [briefNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = letter.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für Empfängerdaten (Umschläge im recipientData-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateRecipientCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Umschläge im recipient-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'recipient');
if (envelopes.length === 0) {
return '';
}
// Prüfe ob Empfängerdaten vorhanden sind (vorname, name, strasse, etc.)
const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land'];
const hasRecipientData = envelopes.some(env =>
env.placeholders && recipientKeys.some(key => env.placeholders[key])
);
if (!hasRecipientData) {
return '';
}
const keys = ['UmschlagNr', ...recipientKeys];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
const values = [umschlagNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = envelope.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für freie Adressen (Umschläge im free-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateFreeAddressCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
return '';
}
// Nur Umschläge im free-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'free');
if (envelopes.length === 0) {
return '';
}
// Freie Adressen haben bis zu 5 Zeilen
const keys = ['UmschlagNr', 'Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5'];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
// Text in Zeilen aufteilen
const textLines = (envelope.text || '').split('\n');
const values = [umschlagNr];
for (let i = 0; i < 5; i++) {
values.push(escapeCSV(textLines[i] || ''));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert CSV für Umschlag-Platzhalter (Umschläge im customText-Modus)
* @param {Array} letters - Array von Letter-Objekten
* @returns {string} - CSV-String
*/
function generateEnvelopePlaceholderCSV(letters) {
if (!Array.isArray(letters) || letters.length === 0) {
console.log('[CSV] generateEnvelopePlaceholderCSV: No letters');
return '';
}
// Nur Umschläge im custom-Modus filtern
const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'custom');
console.log('[CSV] Found custom envelopes:', envelopes.length);
console.log('[CSV] Envelope details:', envelopes.map(e => ({ type: e.type, envelopeType: e.envelopeType, placeholders: e.placeholders })));
if (envelopes.length === 0) {
return '';
}
// Sammle alle unique Keys (außer Standard-Empfängerfelder)
const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land'];
const allKeys = new Set();
envelopes.forEach(envelope => {
if (envelope.placeholders) {
console.log('[CSV] Envelope placeholders keys:', Object.keys(envelope.placeholders));
Object.keys(envelope.placeholders).forEach(key => {
// Nur nicht-Empfängerfelder sammeln
if (!recipientKeys.includes(key.toLowerCase())) {
allKeys.add(key);
console.log('[CSV] Added key:', key);
} else {
console.log('[CSV] Skipped recipient key:', key);
}
});
}
});
console.log('[CSV] All collected keys:', Array.from(allKeys));
if (allKeys.size === 0) {
console.log('[CSV] No custom placeholder keys found for envelopes');
return '';
}
const keys = ['UmschlagNr', ...Array.from(allKeys).sort()];
const header = keys.join(',');
const rows = envelopes.map((envelope, index) => {
const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0');
const values = [umschlagNr];
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
const value = envelope.placeholders?.[key] || '';
values.push(escapeCSV(value));
}
return values.join(',');
});
return [header, ...rows].join('\n');
}
/**
* Generiert alle CSV-Dateien für eine Bestellung
* @param {Array} letters - Array von Letter-Objekten (Briefe und Umschläge)
* @returns {Object} - Objekt mit CSV-Inhalten { placeholders, recipients, envelopePlaceholders, freeAddresses }
*/
function generateAllCSVs(letters) {
const result = {
placeholders: null, // Brief-Platzhalter
recipients: null, // Empfängerdaten für Umschläge (recipientData-Modus)
envelopePlaceholders: null, // Umschlag-Platzhalter (customText-Modus)
freeAddresses: null // Freie Adressen für Umschläge (free-Modus)
};
if (!Array.isArray(letters) || letters.length === 0) {
return result;
}
// Brief-Platzhalter CSV
const placeholderCSV = generatePlaceholderCSV(letters);
if (placeholderCSV) {
result.placeholders = placeholderCSV;
}
// Empfänger CSV (für recipientData-Modus)
const recipientCSV = generateRecipientCSV(letters);
if (recipientCSV) {
result.recipients = recipientCSV;
}
// Umschlag-Platzhalter CSV (für customText-Modus)
const envelopePlaceholderCSV = generateEnvelopePlaceholderCSV(letters);
if (envelopePlaceholderCSV) {
result.envelopePlaceholders = envelopePlaceholderCSV;
}
// Freie Adressen CSV (für free-Modus)
const freeAddressCSV = generateFreeAddressCSV(letters);
if (freeAddressCSV) {
result.freeAddresses = freeAddressCSV;
}
return result;
}
module.exports = {
replacePlaceholders,
generatePlaceholderCSV,
generateRecipientCSV,
generateEnvelopePlaceholderCSV,
generateFreeAddressCSV,
generateAllCSVs,
escapeCSV
};

View File

@@ -0,0 +1,230 @@
const https = require("https");
const { parseStringPromise } = require("xml2js");
const config = require("../config");
// API Request Counter (resets daily)
const stats = {
requestsToday: 0,
textsToday: 0,
lastResetDate: new Date().toDateString(),
};
/**
* Prüft ob der Zähler zurückgesetzt werden muss (neuer Tag)
*/
function checkAndResetDailyStats() {
const today = new Date().toDateString();
if (stats.lastResetDate !== today) {
console.log(`[Scriptalizer] 📅 New day detected, resetting counters`);
console.log(`[Scriptalizer] 📊 Yesterday's stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`);
stats.requestsToday = 0;
stats.textsToday = 0;
stats.lastResetDate = today;
}
}
/**
* Gibt die aktuellen Stats zurück
*/
function getStats() {
checkAndResetDailyStats();
return { ...stats };
}
/**
* Ruft die Scriptalizer API auf
* @param {string} inputText - Text zum Scriptalisieren
* @param {string} fontName - Font-Name (z.B. 'PremiumUltra79')
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<{status: string, outputText: string}>}
*/
async function callScriptalizer(inputText, fontName, errFrequency = 10) {
// Prüfe ob neuer Tag
checkAndResetDailyStats();
return new Promise((resolve, reject) => {
if (!config.scriptalizer.licenseKey) {
reject(new Error("Scriptalizer License Key fehlt in Konfiguration"));
return;
}
// Use x-www-form-urlencoded format
const params = new URLSearchParams();
params.append("LicenseKey", config.scriptalizer.licenseKey);
params.append("FontName", fontName);
params.append("ErrFrequency", String(errFrequency));
params.append("InputText", inputText);
const body = params.toString();
// Check input size (48KB limit)
if (Buffer.byteLength(body) > config.scriptalizer.maxInputSize) {
reject(
new Error(
`Input size exceeds 48KB limit (${Buffer.byteLength(body)} bytes)`
)
);
return;
}
const options = {
hostname: "www.scriptalizer.co.uk",
port: 443,
path: "/QuantumScriptalize.asmx/Scriptalize",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
},
timeout: 30000, // 30 second timeout
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", async () => {
try {
const result = await parseStringPromise(data, {
explicitArray: false,
});
const response = result.ScriptalizerResponse;
if (!response || response.Status !== "OK") {
reject(new Error(response?.Status || "UNKNOWN_STATUS"));
return;
}
// Zähler erhöhen bei erfolgreichem Request
stats.requestsToday++;
console.log(`[Scriptalizer] 📊 API Request #${stats.requestsToday} today completed`);
resolve({ status: "OK", outputText: response.OutputText });
} catch (err) {
reject(
new Error(`Failed to parse Scriptalizer response: ${err.message}`)
);
}
});
});
req.on("error", (err) => {
reject(new Error(`Scriptalizer request failed: ${err.message}`));
});
req.on("timeout", () => {
req.destroy();
reject(new Error("Scriptalizer request timed out"));
});
req.write(body);
req.end();
});
}
/**
* Scriptalisiert mehrere Texte in 25er Batches
* @param {string[]} texts - Array von Texten
* @param {string} font - Font-Key (tilda, alva, ellie)
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<string[]>} - Array von scriptalisierten Texten
*/
async function scriptalizeBatch(texts, font = "tilda", errFrequency = 10) {
if (!Array.isArray(texts) || texts.length === 0) {
return [];
}
const fontName =
config.scriptalizer.fontMap[font.toLowerCase()] ||
config.scriptalizer.fontMap.tilda;
const separator = config.scriptalizer.separator;
const BATCH_SIZE = 18; // 18 Texte pro Scriptalizer API Call (Scriptalizer-API-Limit: 32000 Zeichen pro Call → ca. 1700 Zeichen pro Brief sicher)
console.log(
`[Scriptalizer] Processing ${texts.length} texts in batches of ${BATCH_SIZE}`
);
const allScriptalized = [];
// Teile in 25er Batches auf
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
const totalBatches = Math.ceil(texts.length / BATCH_SIZE);
console.log(
`[Scriptalizer] Processing batch ${batchNumber}/${totalBatches} (${
batch.length
} texts, indices ${i}-${i + batch.length - 1})`
);
// Kombiniere Texte mit Separator
const combinedText = batch.join(separator);
try {
const result = await callScriptalizer(
combinedText,
fontName,
errFrequency
);
// Trenne Ergebnis wieder
const scriptalized = result.outputText.split(separator);
if (scriptalized.length !== batch.length) {
console.warn(
`[Scriptalizer] Batch ${batchNumber}: returned ${scriptalized.length} parts, expected ${batch.length}`
);
}
allScriptalized.push(...scriptalized);
console.log(
`[Scriptalizer] Batch ${batchNumber}/${totalBatches} completed successfully`
);
} catch (err) {
console.error(`[Scriptalizer] Batch ${batchNumber} failed:`, err.message);
throw err;
}
}
// Textzähler erhöhen
stats.textsToday += allScriptalized.length;
console.log(
`[Scriptalizer] All batches completed. Total scriptalized: ${allScriptalized.length}`
);
console.log(
`[Scriptalizer] 📊 Daily stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`
);
return allScriptalized;
}
/**
* Scriptalisiert einen einzelnen Text
* @param {string} text - Text zum Scriptalisieren
* @param {string} font - Font-Key (tilda, alva, ellie)
* @param {number} errFrequency - Fehlerfrequenz
* @returns {Promise<string>} - Scriptalisierter Text
*/
async function scriptalizeSingle(text, font = "tilda", errFrequency = 10) {
const fontName =
config.scriptalizer.fontMap[font.toLowerCase()] ||
config.scriptalizer.fontMap.tilda;
try {
const result = await callScriptalizer(text, fontName, errFrequency);
return result.outputText;
} catch (err) {
console.error("Scriptalizer single call failed:", err.message);
throw err;
}
}
module.exports = {
scriptalizeBatch,
scriptalizeSingle,
getStats,
};