Initial commit

This commit is contained in:
s4luorth
2026-02-07 13:04:04 +01:00
commit 5e0fceab15
82 changed files with 30348 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
node_modules;
npm - debug.log.env.git.gitignore;
README.md;
test - scriptalizer.js;
cache;
output;

View File

@@ -0,0 +1,29 @@
# 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
# PayPal Configuration (for B2C payments)
# Get credentials from PayPal Developer Dashboard:
# https://developer.paypal.com/dashboard/applications/
PAYPAL_CLIENT_ID=your-paypal-client-id
PAYPAL_CLIENT_SECRET=your-paypal-client-secret
# Options: 'sandbox' or 'live'
PAYPAL_ENVIRONMENT=sandbox

7
Docker 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,423 @@
# Deployment Anleitung - Skrift Backend
## Übersicht
Diese Anleitung zeigt, wie du das Skrift Backend auf deinen vServer deployst.
## Voraussetzungen auf dem Server
- Ubuntu/Debian Linux Server
- Docker und Docker Compose installiert
- Nginx Proxy Manager läuft bereits
- SSH-Zugang zum Server
## Option 1: Docker Image über Docker Hub (EMPFOHLEN)
### Schritt 1: Docker Image erstellen und pushen
Auf deinem lokalen Rechner:
```bash
# 1. Ins Backend-Verzeichnis wechseln
cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend"
# 2. Docker Login (benötigt Docker Hub Account)
docker login
# 3. Image bauen (ersetze 'deinusername' mit deinem Docker Hub Username)
docker build -t deinusername/skrift-backend:latest .
# 4. Image zu Docker Hub pushen
docker push deinusername/skrift-backend:latest
```
### Schritt 2: Auf dem Server deployen
Per SSH auf dem Server:
```bash
# 1. Verzeichnis erstellen
mkdir -p /opt/skrift-backend
cd /opt/skrift-backend
# 2. docker-compose.yml erstellen (siehe unten)
nano docker-compose.yml
# 3. .env Datei erstellen
nano .env
# 4. Fonts-Ordner erstellen und Fonts hochladen
mkdir fonts
# Fonts per SCP hochladen (siehe Schritt 3)
# 5. Output-Verzeichnis erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# 6. Container starten
docker-compose up -d
# 7. Logs prüfen
docker-compose logs -f
```
### Schritt 3: Fonts per SCP hochladen
Auf deinem lokalen Rechner:
```bash
# Fonts zum Server kopieren
scp "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend\fonts\*.svg" \
root@dein-server.de:/opt/skrift-backend/fonts/
```
### docker-compose.yml für Server
```yaml
version: '3.8'
services:
skrift-backend:
image: deinusername/skrift-backend:latest # Dein Docker Hub Image
container_name: skrift-backend
restart: unless-stopped
ports:
- "4000:4000" # Oder anderer Port
environment:
- NODE_ENV=production
- PORT=4000
- SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY}
- SCRIPTALIZER_ERR_FREQUENCY=0
- BATCH_SIZE=30
- CACHE_LIFETIME_HOURS=2
- RATE_LIMIT_PER_MINUTE=2
volumes:
- ./fonts:/app/fonts:ro
- skrift-cache:/app/cache
- /var/skrift-output:/app/output
networks:
- skrift-network
volumes:
skrift-cache:
driver: local
networks:
skrift-network:
driver: bridge
```
### .env Datei für Server
```bash
# Scriptalizer API
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0
# Preview Settings
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
# Environment
NODE_ENV=production
```
## Option 2: Direkt vom Server bauen
### Schritt 1: Code auf Server übertragen
```bash
# Auf lokalem Rechner: Komplettes Backend-Verzeichnis kopieren
scp -r "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" \
root@dein-server.de:/opt/skrift-backend/
# Oder mit rsync (besser):
rsync -avz --exclude 'node_modules' --exclude 'output' --exclude 'cache' \
"E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend/" \
root@dein-server.de:/opt/skrift-backend/
```
### Schritt 2: Auf Server bauen und starten
```bash
# Per SSH auf dem Server
cd /opt/skrift-backend
# Output-Verzeichnis erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# Container bauen und starten
docker-compose up -d --build
# Logs prüfen
docker-compose logs -f
```
## Option 3: Mit GitHub/GitLab (BESTE PRAXIS für Produktion)
### Schritt 1: Repository erstellen
```bash
# Auf lokalem Rechner
cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend"
git init
git add .
git commit -m "Initial commit"
# GitHub/GitLab Repository erstellen, dann:
git remote add origin https://github.com/deinusername/skrift-backend.git
git push -u origin main
```
### Schritt 2: Auf Server clonen und deployen
```bash
# Auf dem Server
cd /opt
git clone https://github.com/deinusername/skrift-backend.git
cd skrift-backend
# .env erstellen (nicht in Git!)
nano .env
# Output-Verzeichnis erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# Starten
docker-compose up -d --build
# Bei Updates einfach:
git pull
docker-compose up -d --build
```
## Nginx Proxy Manager Konfiguration
1. Im Nginx Proxy Manager eine neue "Proxy Host" erstellen:
- **Domain Names**: `backend.deine-domain.de` (oder Subdomain deiner Wahl)
- **Scheme**: `http`
- **Forward Hostname/IP**: `skrift-backend` (Container-Name)
- **Forward Port**: `4000`
- **Cache Assets**: aktivieren
- **Block Common Exploits**: aktivieren
- **Websockets Support**: deaktivieren
2. SSL-Zertifikat:
- Tab "SSL" öffnen
- "Request a new SSL Certificate" auswählen
- "Force SSL" aktivieren
- Let's Encrypt Email eingeben
3. Optional - Custom Nginx Configuration:
```nginx
# Größere Request Body Size für große Texte
client_max_body_size 10M;
# Timeouts für lange Scriptalizer-Calls
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
```
## Nützliche Docker-Befehle
```bash
# Container Status prüfen
docker-compose ps
# Logs anzeigen
docker-compose logs -f
# Container neu starten
docker-compose restart
# Container stoppen
docker-compose down
# Container stoppen und Volumes löschen
docker-compose down -v
# In Container-Shell gehen
docker exec -it skrift-backend sh
# Image neu bauen
docker-compose build --no-cache
# Alte Images aufräumen
docker image prune -a
```
## Gesundheits-Check
Nach dem Deployment:
```bash
# Lokal vom Server
curl http://localhost:4000/health
# Über Domain (nach Nginx Proxy Setup)
curl https://backend.deine-domain.de/health
# Sollte zurückgeben:
# {"status":"ok","timestamp":"2026-01-03T..."}
```
## WordPress Integration
In deinem WordPress Plugin (Frontend) die Backend-URL konfigurieren:
```javascript
// WordPress Plugin Einstellungen
const BACKEND_URL = 'https://backend.deine-domain.de';
// API Calls
fetch(`${BACKEND_URL}/api/preview/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sessionId: 'session-uuid',
letters: [...]
})
});
```
## N8N Integration
N8N kann die generierten Dateien im `/var/skrift-output` Verzeichnis abholen:
```bash
# N8N Workflow - File Trigger Node
/var/skrift-output/*/order-metadata.json
```
Oder per API:
```bash
# Webhook in N8N, wenn Bestellung fertig ist
POST https://n8n.deine-domain.de/webhook/order-complete
{
"orderNumber": "SK-2026-01-03-001",
"path": "/var/skrift-output/SK-2026-01-03-001"
}
```
## Troubleshooting
### Container startet nicht
```bash
# Logs prüfen
docker-compose logs
# Typische Probleme:
# - Fonts fehlen: Fonts-Ordner prüfen
# - Port 4000 belegt: Port in docker-compose.yml ändern
# - .env fehlt: .env Datei erstellen
```
### Fonts werden nicht gefunden
```bash
# In Container prüfen
docker exec -it skrift-backend ls -la /app/fonts
# Sollte zeigen:
# tilda.svg
# alva.svg
# ellie.svg
```
### API antwortet nicht
```bash
# Nginx Proxy Logs prüfen
docker logs nginx-proxy-manager
# Backend Logs prüfen
docker-compose logs skrift-backend
# Firewall prüfen
sudo ufw status
sudo ufw allow 4000/tcp # Falls direkt ohne Proxy
```
### Durchgestrichene Wörter erscheinen
```bash
# SCRIPTALIZER_ERR_FREQUENCY in .env auf 0 setzen
echo "SCRIPTALIZER_ERR_FREQUENCY=0" >> .env
# Container neu starten
docker-compose restart
```
## Monitoring
### Einfaches Monitoring mit Healthcheck
```bash
# Cronjob erstellen für Health-Check
crontab -e
# Jede 5 Minuten prüfen
*/5 * * * * curl -f http://localhost:4000/health || systemctl restart skrift-backend
```
### Mit Uptime Kuma (empfohlen)
1. Uptime Kuma installieren (auch als Docker Container)
2. HTTP(s) Monitor erstellen für `https://backend.deine-domain.de/health`
3. Alert bei Ausfall per E-Mail/Telegram
## Backup
```bash
# Fonts sichern (einmalig)
tar -czf skrift-fonts-backup.tar.gz /opt/skrift-backend/fonts
# Output-Dateien sichern (täglich via Cronjob)
tar -czf skrift-output-$(date +%Y%m%d).tar.gz /var/skrift-output
# Zu externem Speicher kopieren
rsync -avz /var/skrift-output/ backup-server:/backups/skrift/
```
## Updates
### Update über Docker Hub
```bash
# Neues Image bauen und pushen (lokal)
docker build -t deinusername/skrift-backend:latest .
docker push deinusername/skrift-backend:latest
# Auf Server updaten
docker-compose pull
docker-compose up -d
```
### Update über Git
```bash
# Auf Server
cd /opt/skrift-backend
git pull
docker-compose up -d --build
```
## Sicherheit
- `.env` Datei NIEMALS in Git committen
- Regelmäßig Updates: `docker-compose pull`
- Nginx Proxy Manager für SSL/TLS
- Firewall: Nur notwendige Ports öffnen
- Rate Limiting ist bereits im Backend implementiert
- Regelmäßige Backups der Output-Dateien
## Support
Bei Problemen:
1. Logs prüfen: `docker-compose logs -f`
2. Health-Endpoint testen: `curl http://localhost:4000/health`
3. Container Status: `docker-compose ps`
4. Ins Container-Shell: `docker exec -it skrift-backend sh`

View File

@@ -0,0 +1,319 @@
# Einfaches Deployment OHNE Docker Hub
## Warum diese Methode?
- ✅ Kein Docker Hub Account nötig
- ✅ Keine Gefahr, dass andere dein Image sehen
- ✅ Schneller und einfacher
- ✅ Perfekt für einen einzelnen Server
## Deployment-Prozess
### Schritt 1: Backend zum Server kopieren
**Option A: Mit SCP (Windows PowerShell oder Git Bash)**
```bash
# Temporäres Archiv erstellen (ohne unnötige Dateien)
cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend"
# Zip erstellen
tar -czf skrift-backend.tar.gz \
--exclude='node_modules' \
--exclude='output' \
--exclude='cache' \
--exclude='.git' \
--exclude='*.md' \
--exclude='bruno-tests' \
--exclude='test-*.js' \
--exclude='generate-*.js' \
src/ fonts/ package.json package-lock.json Dockerfile docker-compose.yml .dockerignore
# Zum Server kopieren
scp skrift-backend.tar.gz root@DEINE-SERVER-IP:/tmp/
# Auf Server entpacken
ssh root@DEINE-SERVER-IP << 'ENDSSH'
mkdir -p /opt/skrift-backend
cd /opt/skrift-backend
tar -xzf /tmp/skrift-backend.tar.gz
rm /tmp/skrift-backend.tar.gz
ENDSSH
```
**Option B: Mit RSYNC (empfohlen, wenn verfügbar)**
```bash
# Direkt synchronisieren (nur geänderte Dateien)
rsync -avz --progress \
--exclude='node_modules' \
--exclude='output' \
--exclude='cache' \
--exclude='.git' \
--exclude='*.md' \
--exclude='bruno-tests' \
--exclude='test-*.js' \
--exclude='generate-*.js' \
"/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend/" \
root@DEINE-SERVER-IP:/opt/skrift-backend/
```
**Option C: Mit WinSCP (GUI)**
1. WinSCP herunterladen und installieren
2. Verbindung zu deinem Server herstellen
3. Verzeichnis erstellen: `/opt/skrift-backend`
4. Diese Ordner hochladen:
- `src/` (kompletter Ordner)
- `fonts/` (kompletter Ordner)
- `package.json`
- `package-lock.json`
- `Dockerfile`
- `docker-compose.yml`
- `.dockerignore`
### Schritt 2: Auf dem Server einrichten
Per SSH auf den Server:
```bash
ssh root@DEINE-SERVER-IP
# Ins Backend-Verzeichnis
cd /opt/skrift-backend
# .env Datei erstellen (WICHTIG!)
cat > .env << 'EOF'
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
NODE_ENV=production
PORT=4000
EOF
# Berechtigungen setzen
chmod 600 .env
# Output-Verzeichnis für N8N erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# Docker Image bauen und Container starten
docker-compose up -d --build
# Logs ansehen (Ctrl+C zum Beenden)
docker-compose logs -f
```
### Schritt 3: Testen
```bash
# Health-Check auf dem Server
curl http://localhost:4000/health
# Sollte zurückgeben:
# {"status":"ok","timestamp":"2026-01-03T..."}
```
### Schritt 4: Nginx Proxy Manager einrichten
1. Öffne Nginx Proxy Manager (z.B. `http://dein-server.de:81`)
2. Login mit Admin-Credentials
3. "Proxy Hosts" → "Add Proxy Host"
**Details Tab:**
- Domain Names: `backend.deine-domain.de` (oder `skrift-backend.deine-domain.de`)
- Scheme: `http`
- Forward Hostname/IP: `skrift-backend` (der Docker Container Name!)
- Forward Port: `4000`
- Cache Assets: ✓ aktivieren
- Block Common Exploits: ✓ aktivieren
- Websockets Support: nicht aktivieren
**SSL Tab:**
- SSL Certificate: "Request a new SSL Certificate"
- Force SSL: ✓ aktivieren
- Email Address: deine-email@domain.de
- I Agree to the Terms: ✓ aktivieren
4. "Save" klicken
### Schritt 5: Finaler Test
Von deinem lokalen Rechner:
```bash
curl https://backend.deine-domain.de/health
```
## Updates durchführen
Wenn du Code-Änderungen gemacht hast:
```bash
# 1. Lokal: Neue Version zum Server kopieren
rsync -avz --progress \
--exclude='node_modules' \
--exclude='output' \
--exclude='cache' \
"/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend/" \
root@DEINE-SERVER-IP:/opt/skrift-backend/
# 2. Auf Server: Container neu bauen
ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose up -d --build"
# 3. Logs prüfen
ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose logs -f"
```
## Automatisches Update-Script
Erstelle eine Datei `update.sh` auf deinem lokalen Rechner:
```bash
#!/bin/bash
SERVER="root@DEINE-SERVER-IP"
REMOTE_PATH="/opt/skrift-backend"
LOCAL_PATH="/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend"
echo "Syncing files to server..."
rsync -avz --progress \
--exclude='node_modules' \
--exclude='output' \
--exclude='cache' \
--exclude='.git' \
"$LOCAL_PATH/" \
"$SERVER:$REMOTE_PATH/"
echo "Rebuilding container on server..."
ssh $SERVER "cd $REMOTE_PATH && docker-compose up -d --build"
echo "Done! Checking logs..."
ssh $SERVER "cd $REMOTE_PATH && docker-compose logs --tail=50"
```
Dann einfach ausführen:
```bash
bash update.sh
```
## Vorteile dieser Methode
**Privat**: Nur auf deinem Server, niemand sonst kann es sehen
**Einfach**: Keine Docker Hub Registrierung nötig
**Schnell**: Direkt auf dem Server gebaut
**Sicher**: Keine Credentials in der Cloud
**Flexibel**: Änderungen sofort deployen
## Troubleshooting
### Verbindung fehlgeschlagen
```bash
# SSH-Verbindung testen
ssh root@DEINE-SERVER-IP
# Falls Fehler: SSH-Key einrichten
ssh-keygen -t rsa -b 4096
ssh-copy-id root@DEINE-SERVER-IP
```
### Container startet nicht
```bash
# Logs prüfen
ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose logs"
# Häufige Probleme:
# - .env fehlt → Schritt 2 wiederholen
# - Fonts fehlen → fonts/ Ordner hochladen
# - Port 4000 belegt → docker-compose.yml anpassen
```
### Nginx Proxy erreicht Container nicht
```bash
# Prüfen ob Container im richtigen Netzwerk ist
ssh root@DEINE-SERVER-IP "docker network ls"
ssh root@DEINE-SERVER-IP "docker network inspect skrift-network"
# Container Name prüfen
ssh root@DEINE-SERVER-IP "docker ps | grep skrift"
# In Nginx Proxy Manager: Forward Hostname = "skrift-backend" (Container-Name)
```
### Build dauert zu lange
```bash
# Cache löschen und neu bauen
ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose build --no-cache"
```
## Sicherheit
- `.env` Datei wird **nur** auf dem Server erstellt (nicht hochgeladen)
- `.dockerignore` verhindert Upload von sensiblen Dateien
- Nur notwendige Ports werden geöffnet (4000 nur intern)
- Nginx Proxy Manager mit SSL-Verschlüsselung
- Rate Limiting ist bereits im Backend implementiert
## Backup
```bash
# Auf dem Server
# 1. Code-Backup
tar -czf /root/skrift-backend-backup-$(date +%Y%m%d).tar.gz /opt/skrift-backend
# 2. Output-Dateien Backup
tar -czf /root/skrift-output-backup-$(date +%Y%m%d).tar.gz /var/skrift-output
# 3. Zu lokalem Rechner herunterladen
scp root@DEINE-SERVER-IP:/root/skrift-*-backup-*.tar.gz ./backups/
```
## Kompletter Workflow - Schritt für Schritt
```bash
# === AUF LOKALEM RECHNER ===
# 1. Ins Verzeichnis wechseln
cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend"
# 2. Zum Server kopieren
rsync -avz --exclude='node_modules' --exclude='output' --exclude='cache' ./ root@SERVER:/opt/skrift-backend/
# === AUF DEM SERVER (per SSH) ===
# 3. Verbinden
ssh root@SERVER
# 4. Setup
cd /opt/skrift-backend
cat > .env << 'EOF'
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0
BATCH_SIZE=30
NODE_ENV=production
EOF
mkdir -p /var/skrift-output
docker-compose up -d --build
# 5. Testen
curl http://localhost:4000/health
# === IN NGINX PROXY MANAGER (Browser) ===
# 6. Proxy Host erstellen
# Domain: backend.deine-domain.de
# Forward to: skrift-backend:4000
# SSL: Let's Encrypt aktivieren
# === FERTIG! ===
# Von überall testen:
curl https://backend.deine-domain.de/health
```

31
Docker Backend/Dockerfile Normal file
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,169 @@
# Quick Start - Deployment in 5 Minuten
## Schnellste Methode: Per SCP auf Server kopieren
### 1. Server-Voraussetzungen prüfen
SSH auf deinen Server und prüfe:
```bash
# Docker installiert?
docker --version
# Docker Compose installiert?
docker-compose --version
# Falls nicht installiert:
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
```
### 2. Backend auf Server kopieren
Auf deinem **lokalen Windows-Rechner** (Git Bash oder WSL):
```bash
# Ins Backend-Verzeichnis
cd "/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend"
# Zum Server kopieren (ersetze USER und SERVER)
rsync -avz \
--exclude 'node_modules' \
--exclude 'output' \
--exclude 'cache' \
--exclude '.git' \
./ root@dein-server.de:/opt/skrift-backend/
```
**Oder mit SCP (wenn rsync nicht verfügbar):**
```bash
# Windows PowerShell
scp -r "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend\*" root@dein-server.de:/opt/skrift-backend/
```
### 3. Auf dem Server starten
SSH auf den Server:
```bash
ssh root@dein-server.de
# Ins Backend-Verzeichnis
cd /opt/skrift-backend
# .env Datei erstellen
cat > .env << 'EOF'
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
NODE_ENV=production
EOF
# Output-Verzeichnis für N8N erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# Container bauen und starten
docker-compose up -d --build
# Logs ansehen
docker-compose logs -f
```
### 4. Testen
```bash
# Auf dem Server
curl http://localhost:4000/health
# Sollte antworten mit:
# {"status":"ok","timestamp":"..."}
```
### 5. Nginx Proxy Manager einrichten
1. Nginx Proxy Manager öffnen (z.B. http://dein-server.de:81)
2. "Proxy Hosts" → "Add Proxy Host"
3. Konfiguration:
- **Domain Names**: `backend.deine-domain.de`
- **Scheme**: `http`
- **Forward Hostname/IP**: `skrift-backend`
- **Forward Port**: `4000`
- **Cache Assets**: ✓
- **Block Common Exploits**: ✓
4. Tab "SSL":
- **SSL Certificate**: "Request a new SSL Certificate"
- **Force SSL**: ✓
- **Email**: deine@email.de
5. Speichern
### 6. Finaler Test
```bash
# Von deinem lokalen Rechner
curl https://backend.deine-domain.de/health
```
## FERTIG!
Dein Backend läuft jetzt auf: `https://backend.deine-domain.de`
### WordPress Integration
In deinem WordPress Plugin die Backend-URL eintragen:
```php
define('SKRIFT_BACKEND_URL', 'https://backend.deine-domain.de');
```
## Wichtige Befehle
```bash
# Container Status
docker-compose ps
# Logs ansehen
docker-compose logs -f
# Container neu starten
docker-compose restart
# Container stoppen
docker-compose down
# Update nach Code-Änderungen
docker-compose up -d --build
```
## Problemlösung
### Container startet nicht
```bash
docker-compose logs
# Häufig: Fonts fehlen oder .env nicht korrekt
```
### Port 4000 schon belegt
```bash
# In docker-compose.yml ändern:
ports:
- "4001:4000" # Anderen externen Port verwenden
```
### Keine Verbindung von außen
```bash
# Firewall prüfen
sudo ufw status
sudo ufw allow 4000/tcp
```
## Nächste Schritte
- Siehe [DEPLOYMENT.md](./DEPLOYMENT.md) für Details
- Siehe [README.md](./README.md) für API-Dokumentation
- N8N Workflow einrichten für automatische Plotter-Übertragung

461
Docker Backend/README.md Normal file
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,21 @@
meta {
name: 1 Health Check
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
tests {
test("Status is 200", function() {
expect(res.status).to.equal(200);
});
test("Scriptalizer is configured", function() {
expect(res.body.scriptalizer).to.equal("configured");
});
}

View File

@@ -0,0 +1,51 @@
meta {
name: 2 Preview Batch - Single Letter
type: http
seq: 2
}
post {
url: {{baseUrl}}/api/preview/batch
body: json
auth: none
}
body:json {
{
"sessionId": "{{sessionId}}",
"batchIndex": 0,
"forceRegenerate": false,
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"text": "Hallo [[Vorname]] [[Nachname]],\n\ndein persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nEr ist gültig bis zum [[Ablaufdatum]].\n\nViele Grüße,\nDein Skrift-Team",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Gutscheincode": "SAVE20",
"Ablaufdatum": "31.12.2026"
}
}
],
"envelopes": []
}
}
}
tests {
test("Status is 200", function() {
expect(res.status).to.equal(200);
});
test("Returns session ID", function() {
expect(res.body.sessionId).to.be.a("string");
});
test("Returns files array", function() {
expect(res.body.files).to.be.an("array");
expect(res.body.files.length).to.be.greaterThan(0);
});
}

View File

@@ -0,0 +1,60 @@
meta {
name: 3 Preview Batch - With Envelope
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/preview/batch
body: json
auth: none
}
body:json {
{
"sessionId": "{{sessionId}}",
"batchIndex": 0,
"forceRegenerate": false,
"config": {
"font": "alva",
"letters": [
{
"index": 0,
"format": "a6p",
"text": "Liebe [[Vorname]],\n\nvielen Dank für deine Bestellung!\n\nHerzliche Grüße",
"placeholders": {
"Vorname": "Anna",
"Nachname": "Schmidt",
"Strasse": "Bahnhofstr. 5",
"PLZ": "80331",
"Ort": "München"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"type": "recipient",
"data": {
"Vorname": "Anna",
"Nachname": "Schmidt",
"Strasse": "Bahnhofstr. 5",
"PLZ": "80331",
"Ort": "München"
}
}
]
}
}
}
tests {
test("Returns letter and envelope", function() {
const files = res.body.files;
const hasLetter = files.some(f => f.type === "letter");
const hasEnvelope = files.some(f => f.type === "envelope");
expect(hasLetter).to.be.true;
expect(hasEnvelope).to.be.true;
});
}

View File

@@ -0,0 +1,39 @@
meta {
name: 4 Preview Batch - Custom Envelope Text
type: http
seq: 4
}
post {
url: {{baseUrl}}/api/preview/batch
body: json
auth: none
}
body:json {
{
"sessionId": "{{sessionId}}-custom",
"batchIndex": 0,
"config": {
"font": "ellie",
"letters": [
{
"index": 0,
"format": "a6l",
"text": "Alles Gute zum Geburtstag!",
"placeholders": {}
}
],
"envelopes": [
{
"index": 0,
"format": "din_lang",
"type": "custom",
"data": {
"customText": "Für meine großartige Freundin Caro"
}
}
]
}
}
}

View File

@@ -0,0 +1,39 @@
meta {
name: 5 Order Finalize
type: http
seq: 5
}
post {
url: {{baseUrl}}/api/order/finalize
body: json
auth: none
}
body:json {
{
"sessionId": "{{sessionId}}",
"orderNumber": "{{orderNumber}}"
}
}
docs {
Finalisiert eine Bestellung aus dem Preview-Cache.
WICHTIG: Vorher muss ein Preview Batch generiert worden sein!
Run "2 Preview Batch - Single Letter" first.
}
tests {
test("Status is 200", function() {
expect(res.status).to.equal(200);
});
test("Returns order number", function() {
expect(res.body.orderNumber).to.equal(bru.getEnvVar("orderNumber"));
});
test("Has output path", function() {
expect(res.body.outputPath).to.be.a("string");
});
}

View File

@@ -0,0 +1,96 @@
meta {
name: 6 Order Generate
type: http
seq: 6
}
post {
url: {{baseUrl}}/api/order/generate
body: json
auth: none
}
body:json {
{
"orderNumber": "SK-2026-01-15-002",
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
},
{
"index": 1,
"format": "a4",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Julia",
"Nachname": "Weber",
"Strasse": "Kastanienallee 7",
"PLZ": "60311",
"Ort": "Frankfurt"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"type": "recipient",
"data": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
},
{
"index": 1,
"format": "c6",
"type": "recipient",
"data": {
"Vorname": "Julia",
"Nachname": "Weber",
"Strasse": "Kastanienallee 7",
"PLZ": "60311",
"Ort": "Frankfurt"
}
}
]
}
}
}
docs {
Generiert eine Bestellung direkt (ohne Preview-Cache).
Use case: Retry nach Fehler oder manuelles Regenerieren.
}
tests {
test("Status is 200", function() {
expect(res.status).to.equal(200);
});
test("Generated 2 letters", function() {
expect(res.body.files.letters).to.equal(2);
});
test("Generated 2 envelopes", function() {
expect(res.body.files.envelopes).to.equal(2);
});
test("Has CSV", function() {
expect(res.body.files.csv).to.be.a("string");
});
}

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Skrift Backend API",
"type": "collection"
}

View File

@@ -0,0 +1,5 @@
vars {
baseUrl: http://localhost:4000
sessionId: test-session-{{$timestamp}}
orderNumber: SK-2026-01-15-001
}

252
Docker Backend/deploy.sh Normal file
View File

@@ -0,0 +1,252 @@
#!/bin/bash
# Skrift Backend - Deployment Script für Server
# Dieses Script automatisiert das Deployment auf dem Server
set -e # Exit bei Fehler
echo "========================================="
echo "Skrift Backend - Deployment Script"
echo "========================================="
echo ""
# Konfiguration
DEPLOY_USER="root"
DEPLOY_HOST=""
DEPLOY_PATH="/opt/skrift-backend"
DOCKER_IMAGE_NAME="skrift-backend"
DOCKER_HUB_USER=""
# Funktion: Hilfe anzeigen
show_help() {
echo "Verwendung: ./deploy.sh [OPTION]"
echo ""
echo "Optionen:"
echo " local - Docker Image lokal bauen und testen"
echo " push - Image zu Docker Hub pushen"
echo " server-scp - Backend per SCP zum Server kopieren"
echo " server-deploy - Auf Server deployen (via Docker Hub)"
echo " server-ssh - SSH-Verbindung zum Server öffnen"
echo " help - Diese Hilfe anzeigen"
echo ""
echo "Beispiele:"
echo " ./deploy.sh local # Lokal testen"
echo " ./deploy.sh push # Image hochladen"
echo " ./deploy.sh server-deploy # Auf Server deployen"
}
# Funktion: Konfiguration prüfen
check_config() {
if [ -z "$DEPLOY_HOST" ]; then
echo "ERROR: DEPLOY_HOST ist nicht gesetzt!"
echo "Bitte in diesem Script DEPLOY_HOST='dein-server.de' setzen"
exit 1
fi
if [ "$1" == "push" ] && [ -z "$DOCKER_HUB_USER" ]; then
echo "ERROR: DOCKER_HUB_USER ist nicht gesetzt!"
echo "Bitte in diesem Script DOCKER_HUB_USER='deinusername' setzen"
exit 1
fi
}
# Funktion: Lokal bauen und testen
build_local() {
echo "Building Docker Image lokal..."
docker build -t $DOCKER_IMAGE_NAME:latest .
echo ""
echo "Image erfolgreich gebaut!"
echo ""
echo "Zum Testen:"
echo " docker run -p 4000:4000 --env-file .env -v \$(pwd)/fonts:/app/fonts:ro $DOCKER_IMAGE_NAME:latest"
echo ""
echo "Dann im Browser: http://localhost:4000/health"
}
# Funktion: Image zu Docker Hub pushen
push_dockerhub() {
check_config "push"
echo "Logging in to Docker Hub..."
docker login
echo "Tagging image..."
docker tag $DOCKER_IMAGE_NAME:latest $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest
echo "Pushing to Docker Hub..."
docker push $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest
echo ""
echo "Image erfolgreich gepusht: $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest"
}
# Funktion: Backend per SCP zum Server kopieren
deploy_scp() {
check_config
echo "Kopiere Backend-Dateien zum Server..."
# Temporäres Verzeichnis für saubere Dateien erstellen
echo "Erstelle sauberes Deployment-Package..."
rm -rf ./deploy-temp
mkdir -p ./deploy-temp
# Nur notwendige Dateien kopieren
rsync -av \
--exclude 'node_modules' \
--exclude 'output' \
--exclude 'cache' \
--exclude '.git' \
--exclude 'deploy-temp' \
--exclude '*.md' \
--exclude 'bruno-tests' \
--exclude 'test-*.js' \
--exclude 'generate-*.js' \
./ ./deploy-temp/
# Zum Server kopieren
echo "Uploading to $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH..."
ssh $DEPLOY_USER@$DEPLOY_HOST "mkdir -p $DEPLOY_PATH"
rsync -avz ./deploy-temp/ $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/
# Fonts extra kopieren
echo "Uploading fonts..."
scp ./fonts/*.svg $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/fonts/
# Cleanup
rm -rf ./deploy-temp
echo ""
echo "Dateien erfolgreich hochgeladen!"
echo ""
echo "Nächste Schritte auf dem Server:"
echo " ssh $DEPLOY_USER@$DEPLOY_HOST"
echo " cd $DEPLOY_PATH"
echo " nano .env # .env Datei erstellen!"
echo " mkdir -p /var/skrift-output"
echo " docker-compose up -d --build"
}
# Funktion: Auf Server deployen (via Docker Hub)
deploy_server() {
check_config "push"
echo "Deploying to server via Docker Hub..."
# SSH Befehle auf Server ausführen
ssh $DEPLOY_USER@$DEPLOY_HOST << ENDSSH
set -e
echo "Creating directories..."
mkdir -p $DEPLOY_PATH
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
cd $DEPLOY_PATH
echo "Checking if .env exists..."
if [ ! -f .env ]; then
echo "ERROR: .env Datei fehlt!"
echo "Bitte zuerst .env erstellen mit:"
echo " SCRIPTALIZER_LICENSE_KEY=..."
echo " SCRIPTALIZER_ERR_FREQUENCY=0"
exit 1
fi
echo "Checking if docker-compose.yml exists..."
if [ ! -f docker-compose.yml ]; then
echo "Creating docker-compose.yml..."
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
skrift-backend:
image: $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest
container_name: skrift-backend
restart: unless-stopped
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- PORT=4000
- SCRIPTALIZER_LICENSE_KEY=\${SCRIPTALIZER_LICENSE_KEY}
- SCRIPTALIZER_ERR_FREQUENCY=0
- BATCH_SIZE=30
- CACHE_LIFETIME_HOURS=2
- RATE_LIMIT_PER_MINUTE=2
volumes:
- ./fonts:/app/fonts:ro
- skrift-cache:/app/cache
- /var/skrift-output:/app/output
networks:
- skrift-network
volumes:
skrift-cache:
driver: local
networks:
skrift-network:
driver: bridge
EOF
fi
echo "Pulling latest image..."
docker-compose pull
echo "Starting containers..."
docker-compose up -d
echo "Waiting for container to be healthy..."
sleep 5
echo "Checking health..."
docker-compose ps
echo ""
echo "Deployment complete!"
echo "Check logs with: docker-compose logs -f"
ENDSSH
echo ""
echo "Deployment auf Server abgeschlossen!"
echo ""
echo "Health-Check:"
echo " curl http://$DEPLOY_HOST:4000/health"
}
# Funktion: SSH zum Server
ssh_server() {
check_config
echo "Connecting to $DEPLOY_USER@$DEPLOY_HOST..."
ssh $DEPLOY_USER@$DEPLOY_HOST
}
# Main Script
case "$1" in
local)
build_local
;;
push)
push_dockerhub
;;
server-scp)
deploy_scp
;;
server-deploy)
deploy_server
;;
server-ssh)
ssh_server
;;
help|--help|-h|"")
show_help
;;
*)
echo "Unbekannte Option: $1"
echo ""
show_help
exit 1
;;
esac

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

View File

@@ -0,0 +1,258 @@
/**
* Generiert 9 Bestellungen mit allen Font/Format/Umschlag Kombinationen
*/
const http = require('http');
const API_URL = 'localhost';
const API_PORT = 4000;
// Texte
const TEXT_A4 = `Sehr geehrte Damen und Herren,
hiermit bestätigen wir den Eingang Ihrer Bestellung und bedanken uns herzlich für Ihr Vertrauen.
Ihre Bestellung wird schnellstmöglich bearbeitet und versendet. Sie erhalten in Kürze eine separate Versandbestätigung mit der Sendungsverfolgungsnummer.
Sollten Sie Fragen zu Ihrer Bestellung haben, stehen wir Ihnen jederzeit gerne zur Verfügung.
Wir wünschen Ihnen viel Freude mit Ihren bestellten Produkten und freuen uns auf eine weitere Zusammenarbeit.
Mit freundlichen Grüßen
Ihr Skrift-Team`;
const TEXT_A6 = `Liebe Grüße!
Vielen Dank für Ihre Bestellung.
Wir wünschen Ihnen einen wundervollen Tag!
Herzlichst,
Ihr Skrift-Team`;
const orders = [
// TILDA
{
orderNumber: 'SK-2026-01-02-TILDA-A4',
font: 'tilda',
format: 'a4',
text: TEXT_A4,
envelopeFormat: 'c6',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-TILDA-A6Q',
font: 'tilda',
format: 'a6l',
text: TEXT_A6,
envelopeFormat: 'din_lang',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-TILDA-A6H',
font: 'tilda',
format: 'a6p',
text: TEXT_A6,
envelopeFormat: 'c6',
envelopeType: 'custom'
},
// ALVA
{
orderNumber: 'SK-2026-01-02-ALVA-A4',
font: 'alva',
format: 'a4',
text: TEXT_A4,
envelopeFormat: 'c6',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-ALVA-A6Q',
font: 'alva',
format: 'a6l',
text: TEXT_A6,
envelopeFormat: 'din_lang',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-ALVA-A6H',
font: 'alva',
format: 'a6p',
text: TEXT_A6,
envelopeFormat: 'c6',
envelopeType: 'custom'
},
// ELLIE
{
orderNumber: 'SK-2026-01-02-ELLIE-A4',
font: 'ellie',
format: 'a4',
text: TEXT_A4,
envelopeFormat: 'c6',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-ELLIE-A6Q',
font: 'ellie',
format: 'a6l',
text: TEXT_A6,
envelopeFormat: 'din_lang',
envelopeType: 'recipient'
},
{
orderNumber: 'SK-2026-01-02-ELLIE-A6H',
font: 'ellie',
format: 'a6p',
text: TEXT_A6,
envelopeFormat: 'c6',
envelopeType: 'custom'
}
];
async function generateOrder(order) {
return new Promise((resolve, reject) => {
const envelope = order.envelopeType === 'recipient'
? {
index: 0,
format: order.envelopeFormat,
font: order.font,
type: 'recipient',
data: {
Vorname: 'Max',
Nachname: 'Mustermann',
Strasse: 'Hauptstraße 1',
PLZ: '10115',
Ort: 'Berlin'
}
}
: {
index: 0,
format: order.envelopeFormat,
font: order.font,
type: 'custom',
data: {
customText: 'Für unsere geschätzten Kunden'
}
};
const requestBody = {
orderNumber: order.orderNumber,
letters: [
{
index: 0,
format: order.format,
font: order.font,
text: order.text,
placeholders: {}
}
],
envelopes: [envelope]
};
const body = JSON.stringify(requestBody);
const options = {
hostname: API_URL,
port: API_PORT,
path: '/api/order/generate',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
},
timeout: 60000
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve({ order: order.orderNumber, result });
} catch (err) {
reject({ order: order.orderNumber, error: err.message, data });
}
});
});
req.on('error', (err) => {
reject({ order: order.orderNumber, error: err.message });
});
req.on('timeout', () => {
req.destroy();
reject({ order: order.orderNumber, error: 'timeout' });
});
req.write(body);
req.end();
});
}
async function generateAllOrders() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ GENERIERE 9 BESTELLUNGEN ║');
console.log('║ 3 Fonts × 3 Formate = 9 Kombinationen ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
const results = [];
for (let i = 0; i < orders.length; i++) {
const order = orders[i];
const num = i + 1;
console.log(`\n[${num}/9] Generiere: ${order.orderNumber}`);
console.log(` Font: ${order.font.toUpperCase()}`);
console.log(` Format: ${order.format.toUpperCase()}`);
console.log(` Umschlag: ${order.envelopeFormat} (${order.envelopeType})`);
try {
const result = await generateOrder(order);
console.log(` ✅ Erfolgreich generiert`);
results.push(result);
} catch (err) {
console.log(` ❌ Fehler: ${err.error || err.message}`);
results.push({ order: order.orderNumber, error: err });
}
// Pause zwischen Requests (Rate Limiting)
if (i < orders.length - 1) {
console.log(` ⏳ Warte 3 Sekunden...`);
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
console.log('\n\n' + '═'.repeat(60));
console.log('ZUSAMMENFASSUNG');
console.log('═'.repeat(60));
const successful = results.filter(r => !r.error);
const failed = results.filter(r => r.error);
console.log(`\n✅ Erfolgreich: ${successful.length}/9`);
console.log(`❌ Fehlgeschlagen: ${failed.length}/9`);
if (successful.length > 0) {
console.log('\n📦 Generierte Bestellungen:');
successful.forEach(r => {
console.log(` - ${r.order}`);
});
}
if (failed.length > 0) {
console.log('\n❌ Fehlgeschlagene Bestellungen:');
failed.forEach(r => {
console.log(` - ${r.order}: ${r.error.error || r.error.message}`);
});
}
console.log('\n' + '═'.repeat(60));
console.log('\n💾 Alle Dateien in: E:\\Dokumente\\05_Skrift\\Frontend_Backend_Konfigurator\\Docker Backend\\output\\');
}
// Start
generateAllOrders().catch(console.error);

1624
Docker Backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"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",
"test:separator": "node test-scriptalizer.js"
},
"keywords": [
"skrift",
"handwriting",
"svg",
"scriptalizer"
],
"author": "",
"license": "ISC",
"dependencies": {
"@paypal/paypal-server-sdk": "^1.0.0",
"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,406 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const SOURCE_DIR = __dirname;
const DEPLOY_DIR = path.join(__dirname, '..', 'DEPLOYMENT_READY');
// Dateien und Ordner die KOPIERT werden sollen
const INCLUDE_FILES = [
// Docker
'Dockerfile',
'docker-compose.yml',
'.dockerignore',
// Package
'package.json',
'package-lock.json',
// Source Code
'src/**/*',
// Fonts
'fonts/**/*.svg',
// Config Example
'.env.example'
];
// Dateien die NICHT kopiert werden sollen
const EXCLUDE_PATTERNS = [
'node_modules',
'output',
'cache',
'.git',
'bruno-tests',
'*.md',
'test-*.js',
'test-*.json',
'test-*.sh',
'generate-*.js',
'server.log',
'.env',
'deploy.sh',
'prepare-deployment.js',
'DEPLOYMENT_READY'
];
function shouldExclude(filePath) {
const relativePath = path.relative(SOURCE_DIR, filePath);
return EXCLUDE_PATTERNS.some(pattern => {
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
return regex.test(path.basename(filePath)) || regex.test(relativePath);
}
return relativePath.startsWith(pattern) || path.basename(filePath) === pattern;
});
}
async function ensureDir(dir) {
try {
await fs.mkdir(dir, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
async function copyFile(src, dest) {
await ensureDir(path.dirname(dest));
await fs.copyFile(src, dest);
}
async function removeDir(dir) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await removeDir(fullPath);
} else {
await fs.unlink(fullPath);
}
}
await fs.rmdir(dir);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
async function pathExists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function copyDirectory(src, dest) {
const entries = await fs.readdir(src, { withFileTypes: true });
await ensureDir(dest);
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (shouldExclude(srcPath)) {
console.log(`⏭️ Skipping: ${path.relative(SOURCE_DIR, srcPath)}`);
continue;
}
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await copyFile(srcPath, destPath);
console.log(`✅ Copied: ${path.relative(SOURCE_DIR, srcPath)}`);
}
}
}
async function main() {
console.log('🚀 Preparing Deployment Package...\n');
// Deployment-Verzeichnis erstellen/leeren
if (await pathExists(DEPLOY_DIR)) {
console.log('🗑️ Cleaning existing deployment directory...');
await removeDir(DEPLOY_DIR);
}
await ensureDir(DEPLOY_DIR);
console.log(`📁 Created: ${DEPLOY_DIR}\n`);
// Dateien kopieren
console.log('📋 Copying production files...\n');
await copyDirectory(SOURCE_DIR, DEPLOY_DIR);
// Produktions-.env.example erstellen
const envExample = `# Skrift Backend - Production Environment Variables
# Scriptalizer API Configuration
SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66
SCRIPTALIZER_ERR_FREQUENCY=0
# Preview Settings
BATCH_SIZE=30
CACHE_LIFETIME_HOURS=2
RATE_LIMIT_PER_MINUTE=2
# Environment
NODE_ENV=production
PORT=4000
`;
await fs.writeFile(path.join(DEPLOY_DIR, '.env.example'), envExample);
console.log('✅ Created: .env.example\n');
// README für Deployment erstellen
const deployReadme = `# Skrift Backend - Deployment Package
Dieses Verzeichnis enthält alle notwendigen Dateien für das Deployment auf den Server.
## Schnellstart
### 1. Zum Server kopieren
\`\`\`bash
# Mit SCP
scp -r * root@DEIN-SERVER:/opt/skrift-backend/
# Oder mit rsync (falls verfügbar)
rsync -avz ./ root@DEIN-SERVER:/opt/skrift-backend/
\`\`\`
### 2. Auf dem Server einrichten
\`\`\`bash
ssh root@DEIN-SERVER
cd /opt/skrift-backend
# .env erstellen (aus .env.example)
cp .env.example .env
nano .env # Prüfen und anpassen falls nötig
# Output-Verzeichnis erstellen
mkdir -p /var/skrift-output
chmod 755 /var/skrift-output
# Container starten
docker-compose up -d --build
# Logs prüfen
docker-compose logs -f
\`\`\`
### 3. Testen
\`\`\`bash
curl http://localhost:4000/health
\`\`\`
## Enthaltene Dateien
- \`src/\` - Backend Source Code
- \`fonts/\` - SVG Fonts (Tilda, Alva, Ellie)
- \`Dockerfile\` - Docker Image Konfiguration
- \`docker-compose.yml\` - Docker Compose Konfiguration
- \`package.json\` - Node.js Dependencies
- \`.env.example\` - Environment Variables Template
## Wichtig
⚠️ **SCRIPTALIZER_ERR_FREQUENCY=0** ist bereits gesetzt - keine durchgestrichenen Wörter!
## Nginx Proxy Manager
Nach dem Start in Nginx Proxy Manager konfigurieren:
- Domain: backend.deine-domain.de
- Forward to: skrift-backend:4000
- SSL: Let's Encrypt aktivieren
## Support
Bei Problemen: \`docker-compose logs -f\`
`;
await fs.writeFile(path.join(DEPLOY_DIR, 'README.txt'), deployReadme);
console.log('✅ Created: README.txt\n');
// Upload-Script erstellen
const uploadScript = `#!/bin/bash
# Skrift Backend - Upload Script
# Dieses Script lädt das Backend auf den Server hoch
# KONFIGURATION - BITTE ANPASSEN!
SERVER_USER="root"
SERVER_HOST="" # z.B. "123.456.789.0" oder "dein-server.de"
SERVER_PATH="/opt/skrift-backend"
# Farben für Output
RED='\\033[0;31m'
GREEN='\\033[0;32m'
YELLOW='\\033[1;33m'
NC='\\033[0m' # No Color
# Funktion: Fehler anzeigen und beenden
error_exit() {
echo -e "\${RED}❌ ERROR: \$1\${NC}" >&2
exit 1
}
# Prüfen ob Server konfiguriert ist
if [ -z "$SERVER_HOST" ]; then
error_exit "SERVER_HOST ist nicht gesetzt! Bitte in upload.sh die Variable SERVER_HOST setzen."
fi
echo -e "\${GREEN}🚀 Skrift Backend Upload\${NC}"
echo "======================================"
echo "Server: \$SERVER_USER@\$SERVER_HOST"
echo "Path: \$SERVER_PATH"
echo ""
# Prüfen ob SSH-Verbindung funktioniert
echo -e "\${YELLOW}🔍 Testing SSH connection...\${NC}"
ssh -o ConnectTimeout=5 -o BatchMode=yes \$SERVER_USER@\$SERVER_HOST "echo '✅ SSH connection successful'" || error_exit "SSH connection failed"
echo ""
# Verzeichnis auf Server erstellen
echo -e "\${YELLOW}📁 Creating directory on server...\${NC}"
ssh \$SERVER_USER@\$SERVER_HOST "mkdir -p \$SERVER_PATH" || error_exit "Failed to create directory"
echo ""
# Dateien hochladen
echo -e "\${YELLOW}📤 Uploading files...\${NC}"
scp -r * \$SERVER_USER@\$SERVER_HOST:\$SERVER_PATH/ || error_exit "Upload failed"
echo ""
echo -e "\${GREEN}✅ Upload successful!\${NC}"
echo ""
echo "Next steps:"
echo "1. SSH to server: ssh \$SERVER_USER@\$SERVER_HOST"
echo "2. Go to directory: cd \$SERVER_PATH"
echo "3. Create .env: cp .env.example .env"
echo "4. Create output dir: mkdir -p /var/skrift-output"
echo "5. Start container: docker-compose up -d --build"
echo "6. Check logs: docker-compose logs -f"
`;
await fs.writeFile(path.join(DEPLOY_DIR, 'upload.sh'), uploadScript);
await fs.chmod(path.join(DEPLOY_DIR, 'upload.sh'), 0o755);
console.log('✅ Created: upload.sh\n');
// Windows Batch Upload-Script
const uploadBat = `@echo off
REM Skrift Backend - Windows Upload Script
REM KONFIGURATION - BITTE ANPASSEN!
set SERVER_USER=root
set SERVER_HOST=
set SERVER_PATH=/opt/skrift-backend
if "%SERVER_HOST%"=="" (
echo ERROR: SERVER_HOST ist nicht gesetzt!
echo Bitte in upload.bat die Variable SERVER_HOST setzen.
pause
exit /b 1
)
echo ========================================
echo Skrift Backend Upload
echo ========================================
echo Server: %SERVER_USER%@%SERVER_HOST%
echo Path: %SERVER_PATH%
echo.
echo Uploading files...
echo.
scp -r * %SERVER_USER%@%SERVER_HOST%:%SERVER_PATH%/
if %ERRORLEVEL% NEQ 0 (
echo.
echo ERROR: Upload fehlgeschlagen!
pause
exit /b 1
)
echo.
echo ========================================
echo Upload erfolgreich!
echo ========================================
echo.
echo Naechste Schritte:
echo 1. SSH to server: ssh %SERVER_USER%@%SERVER_HOST%
echo 2. Go to directory: cd %SERVER_PATH%
echo 3. Create .env: cp .env.example .env
echo 4. Create output dir: mkdir -p /var/skrift-output
echo 5. Start container: docker-compose up -d --build
echo 6. Check logs: docker-compose logs -f
echo.
pause
`;
await fs.writeFile(path.join(DEPLOY_DIR, 'upload.bat'), uploadBat);
console.log('✅ Created: upload.bat\n');
// Statistik
const stats = await getDirectoryStats(DEPLOY_DIR);
console.log('');
console.log('📊 Deployment Package Stats:');
console.log('=====================================');
console.log(`📁 Total Files: ${stats.files}`);
console.log(`📂 Total Directories: ${stats.dirs}`);
console.log(`💾 Total Size: ${formatBytes(stats.size)}`);
console.log('');
console.log('✅ Deployment Package Ready!');
console.log('');
console.log(`📦 Location: ${DEPLOY_DIR}`);
console.log('');
console.log('Next steps:');
console.log('1. Gehe ins Verzeichnis: cd DEPLOYMENT_READY');
console.log('2. Passe upload.sh oder upload.bat an (SERVER_HOST setzen)');
console.log('3. Führe aus: ./upload.sh (Linux/Mac) oder upload.bat (Windows)');
}
async function getDirectoryStats(dir) {
let files = 0;
let dirs = 0;
let size = 0;
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
dirs++;
const subStats = await getDirectoryStats(fullPath);
files += subStats.files;
dirs += subStats.dirs;
size += subStats.size;
} else {
files++;
const stat = await fs.stat(fullPath);
size += stat.size;
}
}
return { files, dirs, size };
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
main().catch(err => {
console.error('❌ Error:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,414 @@
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');
/**
* 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}`);
if (envelopes && envelopes.length > 0) {
console.log(`[Order] Envelope data provided: ${envelopes.length} envelopes`);
}
// 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 orderMetadata = {
orderNumber,
sessionId,
finalizedAt: new Date().toISOString(),
letterCount: copiedLetters.length,
envelopeCount: copiedEnvelopes.length,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther
};
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 || '';
// Replace placeholders if present
if (letter.placeholders && typeof letter.placeholders === 'object') {
text = replacePlaceholders(text, letter.placeholders);
}
return {
...letter,
// Behalte den ursprünglichen Index vom Frontend, oder verwende loopIndex als Fallback
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex, // Für scriptalizedMap Zuordnung
processedText: text,
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient'
};
});
// 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)) {
console.log(`[Order] Scriptalizing ${fontLetters.length} texts with font: ${font}`);
const textsToScriptalize = fontLetters.map(l => l.processedText);
try {
const scriptalizedTexts = await scriptalizeBatch(textsToScriptalize, font, config.scriptalizer.errFrequency);
fontLetters.forEach((letter, i) => {
// Verwende loopIndex für die Map-Zuordnung, da das der Index im processedLetters Array ist
scriptalizedMap.set(letter.loopIndex, scriptalizedTexts[i] || letter.processedText);
});
} 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 svgContent;
try {
if (letter.type === 'envelope') {
svgContent = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font }
);
} else {
svgContent = generateLetterSVG(
scriptalizedText,
letter.format,
{ font: letter.font }
);
}
} 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, svgContent, 'utf8');
generatedEnvelopes.push(filename);
} else {
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, svgContent, '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 orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
letterCount: generatedLetters.length,
envelopeCount: generatedEnvelopes.length,
letters: generatedLetters,
envelopes: generatedEnvelopes,
fonts: Object.keys(lettersByFont),
hasPlaceholders,
csvFiles
};
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);
}
}
module.exports = {
finalizeOrder,
generateOrder,
uploadMotif
};

View File

@@ -0,0 +1,200 @@
const {
Client,
Environment,
LogLevel,
OrdersController
} = require('@paypal/paypal-server-sdk');
const config = require('../../config');
// PayPal Client initialisieren
let client = null;
let ordersController = null;
function initializePayPalClient() {
if (client) return;
const { clientId, clientSecret, environment } = config.paypal;
if (!clientId || !clientSecret) {
console.warn('[PayPal] Client ID oder Secret nicht konfiguriert');
return;
}
client = new Client({
clientCredentialsAuthCredentials: {
oAuthClientId: clientId,
oAuthClientSecret: clientSecret,
},
timeout: 0,
environment: environment === 'live' ? Environment.Production : Environment.Sandbox,
logging: {
logLevel: LogLevel.Info,
logRequest: { logBody: true },
logResponse: { logHeaders: true },
},
});
ordersController = new OrdersController(client);
console.log(`[PayPal] Client initialisiert (${environment})`);
}
// Initialisierung beim Laden des Moduls
initializePayPalClient();
/**
* Erstellt eine PayPal-Bestellung
* POST /api/paypal/orders
*/
async function createOrder(req, res) {
try {
if (!ordersController) {
return res.status(503).json({
error: 'PayPal nicht konfiguriert',
message: 'PayPal Client ID und Secret müssen in den Umgebungsvariablen gesetzt sein'
});
}
const { amount, currency = 'EUR', orderData } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({
error: 'Ungültiger Betrag',
message: 'Der Bestellbetrag muss größer als 0 sein'
});
}
// Betrag auf 2 Dezimalstellen formatieren
const formattedAmount = parseFloat(amount).toFixed(2);
// Produktbeschreibung erstellen
const productName = orderData?.product?.label || 'Skrift Handschriftservice';
const quantity = orderData?.quantity || 1;
const collect = {
body: {
intent: 'CAPTURE',
purchaseUnits: [
{
amount: {
currencyCode: currency,
value: formattedAmount,
breakdown: {
itemTotal: {
currencyCode: currency,
value: formattedAmount,
},
},
},
items: [
{
name: productName,
unitAmount: {
currencyCode: currency,
value: formattedAmount,
},
quantity: '1',
description: `${quantity}x ${productName}`,
sku: orderData?.product?.key || 'skrift-order',
},
],
},
],
paymentSource: {
paypal: {
experienceContext: {
userAction: 'PAY_NOW',
brandName: 'Skrift',
locale: 'de-DE',
landingPage: 'NO_PREFERENCE',
shippingPreference: 'NO_SHIPPING',
},
},
},
},
prefer: 'return=minimal',
};
console.log('[PayPal] Creating order:', { amount: formattedAmount, currency });
const { body, ...httpResponse } = await ordersController.createOrder(collect);
const jsonResponse = JSON.parse(body);
console.log('[PayPal] Order created:', jsonResponse.id);
res.status(httpResponse.statusCode).json(jsonResponse);
} catch (error) {
console.error('[PayPal] Create order error:', error.message);
res.status(500).json({
error: 'Bestellung konnte nicht erstellt werden',
message: error.message
});
}
}
/**
* Erfasst eine PayPal-Zahlung
* POST /api/paypal/orders/:orderID/capture
*/
async function captureOrder(req, res) {
try {
if (!ordersController) {
return res.status(503).json({
error: 'PayPal nicht konfiguriert',
message: 'PayPal Client ID und Secret müssen in den Umgebungsvariablen gesetzt sein'
});
}
const { orderID } = req.params;
if (!orderID) {
return res.status(400).json({
error: 'Order ID fehlt',
message: 'Die PayPal Order ID muss angegeben werden'
});
}
const collect = {
id: orderID,
prefer: 'return=minimal',
};
console.log('[PayPal] Capturing order:', orderID);
const { body, ...httpResponse } = await ordersController.captureOrder(collect);
const jsonResponse = JSON.parse(body);
console.log('[PayPal] Order captured:', {
id: jsonResponse.id,
status: jsonResponse.status
});
res.status(httpResponse.statusCode).json(jsonResponse);
} catch (error) {
console.error('[PayPal] Capture order error:', error.message);
res.status(500).json({
error: 'Zahlung konnte nicht erfasst werden',
message: error.message
});
}
}
/**
* Prüft den PayPal-Konfigurationsstatus
* GET /api/paypal/status
*/
function getStatus(req, res) {
const { clientId, environment } = config.paypal;
res.json({
configured: !!(clientId && config.paypal.clientSecret),
environment,
// Nur die ersten/letzten Zeichen der Client ID anzeigen
clientIdPreview: clientId ? `${clientId.substring(0, 8)}...${clientId.substring(clientId.length - 4)}` : null
});
}
module.exports = {
createOrder,
captureOrder,
getStatus
};

View File

@@ -0,0 +1,322 @@
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");
/**
* 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 || "";
// Replace placeholders if present
if (letter.placeholders && typeof letter.placeholders === "object") {
text = replacePlaceholders(text, letter.placeholders);
}
return {
...letter,
// Verwende den vom Frontend gesendeten Index, oder fallback auf loopIndex
index: letter.index !== undefined ? letter.index : loopIndex,
processedText: text,
format: letter.format || "a4",
font: letter.font || "tilda",
type: letter.type || "letter",
envelopeType: letter.envelopeType || "recipient",
};
});
console.log(
"[Preview] Final indices being used for files:",
processedLetters.map((l) => l.index)
);
// Step 2: Batch scriptalize texts
const textsToScriptalize = processedLetters.map((l) => l.processedText);
const font = processedLetters[0]?.font || "tilda";
console.log(
`[Preview] Scriptalizing ${textsToScriptalize.length} texts with font: ${font}`
);
let scriptalizedTexts;
try {
scriptalizedTexts = await scriptalizeBatch(
textsToScriptalize,
font,
config.scriptalizer.errFrequency
);
} catch (err) {
console.error("[Preview] Scriptalization failed:", err.message);
return res.status(500).json({
error: "Scriptalization failed",
message: err.message,
});
}
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") {
// Umschläge haben keine Zeilenbegrenzung - nur SVG zurückgeben
const svgContent = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font }
);
svgResult = { svg: svgContent, lineCount: 0, lineLimit: 0, overflow: false };
} else {
// Briefe: Zeileninfo mit zurückgeben
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font,
});
}
} 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,94 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Multer konfigurieren für Datei-Uploads (temporär im Speicher)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
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);
module.exports = router;

View File

@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const paypalController = require('../controllers/paypal-controller');
// PayPal-Konfigurationsstatus
router.get('/status', paypalController.getStatus);
// Bestellung erstellen
router.post('/orders', paypalController.createOrder);
// Zahlung erfassen
router.post('/orders/:orderID/capture', paypalController.captureOrder);
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,122 @@
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: 9,
};
// Layout Konfiguration pro Format + Orientation
const FORMAT_LAYOUTS = {
// A4 Hochformat
a4: {
marginTopMm: 25,
marginLeftMm: 20,
lineHeightFactor: 1.35,
},
// A6 Hochformat
a6p: {
marginTopMm: 12,
marginLeftMm: 10,
lineHeightFactor: 1.3,
},
// A6 Querformat (landscape)
a6l: {
marginTopMm: 10,
marginLeftMm: 8,
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),
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,212 @@
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;
}
// 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 = 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;
}
/**
* Breite einer Textzeile in Pixeln messen.
*/
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
let x = 0;
for (const ch of String(text || '')) {
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.
*/
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) {
const words = para.split(/\s+/).filter(Boolean);
if (words.length === 0) {
allLines.push('');
continue;
}
let currentLine = '';
for (const word of words) {
// Wort alleine zu breit → Zeichenweise brechen
if (measure(word) > maxWidthPx) {
if (currentLine) {
allLines.push(currentLine);
currentLine = '';
}
let buf = '';
for (const ch of word) {
const candidate = buf + ch;
if (measure(candidate) > maxWidthPx && buf) {
allLines.push(buf);
buf = ch;
} else {
buf = candidate;
}
}
if (buf) currentLine = buf;
continue;
}
if (!currentLine) {
currentLine = word;
continue;
}
const candidate = `${currentLine} ${word}`;
if (measure(candidate) <= maxWidthPx) {
currentLine = candidate;
} else {
allLines.push(currentLine);
currentLine = word;
}
}
if (currentLine) {
allLines.push(currentLine);
}
}
return allLines;
}
/**
* Textzeilen in Pfade mit Transform-Matrix umwandeln.
*/
function layoutTextToPaths({
lines,
fontFileName,
fontSizePx,
startX,
startY,
lineHeightPx,
}) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const resultPaths = [];
let baselineY = startY;
for (const line of lines) {
let x = startX;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
const glyph = glyphs[ch];
if (!glyph) {
// Leerzeichen mit festem Abstand
const spacePx = fontSizePx * 0.4;
x += spacePx;
continue;
}
const d = glyph.d;
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
// Einfache Transform-Matrix ohne Rotation
const transform = `matrix(${scale},0,0,${-scale},${x},${baselineY})`;
resultPaths.push({
d,
transform,
});
x += advPx;
}
baselineY += lineHeightPx;
}
return resultPaths;
}
module.exports = {
measureTextWidthPx,
wrapTextToLinesByWidth,
layoutTextToPaths
};

View File

@@ -0,0 +1,213 @@
const {
FONT_SIZE_PX,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
} = require('./page-layout');
const {
layoutTextToPaths,
wrapTextToLinesByWidth,
measureTextWidthPx
} = require('./svg-font-engine');
// 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 { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format);
// Automatischer Zeilenumbruch
const maxWidthPx = widthPx - 2 * marginLeftPx;
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,
});
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>`;
const lineLimit = getLineLimit(format);
const lineCount = lines.length;
return {
svg: svg.trim(),
lineCount,
lineLimit,
overflow: lineCount > 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': Links unten in Ecke
const maxWidthPx = widthPx - marginLeftPx - 20;
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,
});
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,
});
}
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>`;
return svg.trim();
}
module.exports = {
generateLetterSVG,
generateEnvelopeSVG
};

View File

@@ -0,0 +1,55 @@
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 paypalRoutes = require('./api/routes/paypal-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);
app.use('/api/paypal', paypalRoutes);
// 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 = 25; // 25 Texte pro Scriptalizer API Call
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,
};

View File

@@ -0,0 +1,58 @@
{
"orders": [
{
"orderNumber": "SK-2026-01-02-TILDA-A4",
"font": "tilda",
"format": "a4",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-TILDA-A6Q",
"font": "tilda",
"format": "a6l",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-TILDA-A6H",
"font": "tilda",
"format": "a6p",
"envelopeType": "custom"
},
{
"orderNumber": "SK-2026-01-02-ALVA-A4",
"font": "alva",
"format": "a4",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-ALVA-A6Q",
"font": "alva",
"format": "a6l",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-ALVA-A6H",
"font": "alva",
"format": "a6p",
"envelopeType": "custom"
},
{
"orderNumber": "SK-2026-01-02-ELLIE-A4",
"font": "ellie",
"format": "a4",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-ELLIE-A6Q",
"font": "ellie",
"format": "a6l",
"envelopeType": "recipient"
},
{
"orderNumber": "SK-2026-01-02-ELLIE-A6H",
"font": "ellie",
"format": "a6p",
"envelopeType": "custom"
}
]
}

View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Test Skrift Backend API
BASE_URL="http://localhost:4000"
SESSION_ID="test-$(date +%s)"
echo "=========================================="
echo "Testing Skrift Backend API"
echo "=========================================="
echo -e "\n1. Health Check..."
curl -s "$BASE_URL/health" | python -m json.tool
echo -e "\n\n2. Preview Batch (Single Letter)..."
curl -s -X POST "$BASE_URL/api/preview/batch" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "'"$SESSION_ID"'",
"batchIndex": 0,
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"text": "Hallo [[Vorname]] [[Nachname]],\n\ndein persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nViele Grüße",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Gutscheincode": "SAVE20"
}
}
],
"envelopes": []
}
}' | python -m json.tool
echo -e "\n\nDone! Session ID: $SESSION_ID"

View File

@@ -0,0 +1,85 @@
{
"sessionId": "test-complete-workflow",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung mit der Nummer [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nEr ist gültig bis zum [[Ablaufdatum]].\n\nVielen Dank für Ihr Vertrauen!\n\nMit freundlichen Grüßen\nIhr Skrift-Team",
"placeholders": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Bestellnummer": "SK-2026-001",
"Gutscheincode": "SAVE20",
"Ablaufdatum": "31.12.2026",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
},
{
"index": 1,
"format": "a6p",
"font": "alva",
"text": "Liebe/r [[Vorname]],\n\nvielen Dank für deine Bestellung!\n\nDein Code: [[Gutscheincode]]\n\nHerzliche Grüße",
"placeholders": {
"Vorname": "Anna",
"Nachname": "Schmidt",
"Gutscheincode": "WINTER50",
"Strasse": "Bahnhofstr. 5",
"PLZ": "80331",
"Ort": "München"
}
},
{
"index": 2,
"format": "a6l",
"font": "ellie",
"text": "Alles Gute zum Geburtstag, [[Vorname]]!\n\nWir wünschen dir einen wundervollen Tag!",
"placeholders": {
"Vorname": "Julia",
"Nachname": "Weber",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"font": "tilda",
"type": "recipient",
"data": {
"Vorname": "Max",
"Nachname": "Mustermann",
"Strasse": "Hauptstr. 1",
"PLZ": "10115",
"Ort": "Berlin"
}
},
{
"index": 1,
"format": "c6",
"font": "alva",
"type": "recipient",
"data": {
"Vorname": "Anna",
"Nachname": "Schmidt",
"Strasse": "Bahnhofstr. 5",
"PLZ": "80331",
"Ort": "München"
}
},
{
"index": 2,
"format": "din_lang",
"font": "ellie",
"type": "custom",
"data": {
"customText": "Für meine liebe Freundin Julia"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
"sessionId": "test-complete-workflow",
"orderNumber": "SK-2026-01-02-001"
}

View File

@@ -0,0 +1,36 @@
{
"orderNumber": "SK-2026-01-02-002",
"config": {
"font": "tilda",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"font": "tilda",
"type": "recipient",
"data": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
}
]
}
}

View File

@@ -0,0 +1,52 @@
{
"orderNumber": "SK-2026-01-02-003",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!\n\nMit freundlichen Grüßen",
"placeholders": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
},
{
"index": 1,
"format": "a6p",
"font": "alva",
"text": "Hallo [[Vorname]]!\n\nSchöne Grüße!",
"placeholders": {
"Vorname": "Sarah",
"Nachname": "Fischer"
}
}
],
"envelopes": [
{
"index": 0,
"format": "c6",
"font": "tilda",
"type": "recipient",
"data": {
"Vorname": "Thomas",
"Nachname": "Müller",
"Strasse": "Lindenweg 12",
"PLZ": "50667",
"Ort": "Köln"
}
},
{
"index": 1,
"format": "din_lang",
"font": "alva",
"type": "custom",
"data": {
"customText": "Für Sarah - Alles Gute!"
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"sessionId": "test-simple",
"letters": [
{
"index": 0,
"format": "a4",
"font": "tilda",
"text": "Hallo Max Mustermann,\n\ndein persönlicher Gutscheincode lautet: SAVE20\n\nViele Grüße",
"placeholders": {}
}
]
}

View File

@@ -0,0 +1,127 @@
/**
* Zeigt den exakten Scriptalizer API-Call für Tilda Font
*/
const https = require('https');
const LICENSE_KEY = 'f9918b40-d11c-11f0-b558-0800200c9a66';
const FONT_NAME = 'PremiumUltra79'; // Tilda
const ERR_FREQUENCY = 0;
const INPUT_TEXT = 'Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen\nIhr Skrift-Team';
// Build request body (x-www-form-urlencoded)
const params = new URLSearchParams();
params.append('LicenseKey', LICENSE_KEY);
params.append('FontName', FONT_NAME);
params.append('ErrFrequency', String(ERR_FREQUENCY));
params.append('InputText', INPUT_TEXT);
const body = params.toString();
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ SCRIPTALIZER API CALL - TILDA FONT ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
console.log('📍 ENDPOINT:');
console.log('https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize\n');
console.log('📋 METHOD:');
console.log('POST\n');
console.log('📦 HEADERS:');
console.log({
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
});
console.log('');
console.log('📝 REQUEST BODY (x-www-form-urlencoded):');
console.log('─'.repeat(60));
console.log(body);
console.log('─'.repeat(60));
console.log('');
console.log('📊 DECODED PARAMETERS:');
console.log({
LicenseKey: LICENSE_KEY,
FontName: FONT_NAME,
ErrFrequency: ERR_FREQUENCY,
InputText: INPUT_TEXT.substring(0, 100) + '...'
});
console.log('');
console.log('🔧 CURL COMMAND:');
console.log('─'.repeat(60));
console.log(`curl -X POST 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize' \\
-H 'Content-Type: application/x-www-form-urlencoded; charset=utf-8' \\
-d 'LicenseKey=${LICENSE_KEY}' \\
-d 'FontName=${FONT_NAME}' \\
-d 'ErrFrequency=${ERR_FREQUENCY}' \\
-d 'InputText=${INPUT_TEXT.replace(/\n/g, '\\n')}'`);
console.log('─'.repeat(60));
console.log('');
console.log('📡 SENDING REQUEST TO SCRIPTALIZER...\n');
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
};
const req = https.request(options, (res) => {
let data = '';
console.log(`📥 RESPONSE STATUS: ${res.statusCode}`);
console.log(`📥 RESPONSE HEADERS:`);
console.log(res.headers);
console.log('');
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
console.log('📄 RESPONSE BODY (XML):');
console.log('─'.repeat(60));
console.log(data);
console.log('─'.repeat(60));
console.log('');
// Parse response
if (data.includes('<Status>OK</Status>')) {
console.log('✅ Status: OK');
// Extract OutputText
const outputMatch = data.match(/<OutputText>([\s\S]*?)<\/OutputText>/);
if (outputMatch) {
const outputText = outputMatch[1];
console.log('\n📝 SCRIPTALIZED TEXT (first 200 chars):');
console.log('─'.repeat(60));
console.log(outputText.substring(0, 200) + '...');
console.log('─'.repeat(60));
console.log(`\n📏 OUTPUT LENGTH: ${outputText.length} characters`);
}
} else {
console.log('❌ Error in response');
}
});
});
req.on('error', (err) => {
console.error('❌ Request error:', err.message);
});
req.on('timeout', () => {
req.destroy();
console.error('❌ Request timeout');
});
req.write(body);
req.end();

View File

@@ -0,0 +1,198 @@
/**
* Scriptalizer Separator Test
* Tests which separator character survives Scriptalizer API processing
*/
const https = require('https');
const { parseStringPromise } = require('xml2js');
const LICENSE_KEY = 'f9918b40-d11c-11f0-b558-0800200c9a66';
// Test different separators
const SEPARATORS = [
{ name: 'Triple Pipe', value: '|||' },
{ name: 'Triple Tilde', value: '~~~' },
{ name: 'Triple Hash', value: '###' },
{ name: 'Paragraph Signs', value: '§§§' },
{ name: 'Double Pipe', value: '||' },
{ name: 'Custom Marker', value: '___SKRIFT___' },
{ name: 'Newline + Dashes', value: '\n---\n' },
];
async function callScriptalizer(inputText) {
return new Promise((resolve, reject) => {
// Use x-www-form-urlencoded format
const params = new URLSearchParams();
params.append('LicenseKey', LICENSE_KEY);
params.append('FontName', 'PremiumUltra79');
params.append('ErrFrequency', '10');
params.append('InputText', inputText);
const body = params.toString();
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)
}
};
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;
}
resolve({ status: 'OK', outputText: response.OutputText });
} catch (err) {
reject(err);
}
});
});
req.on('error', (err) => {
reject(err);
});
req.write(body);
req.end();
});
}
async function testSeparator(separator) {
console.log(`\n${'='.repeat(60)}`);
console.log(`Testing: ${separator.name} (${JSON.stringify(separator.value)})`);
console.log('='.repeat(60));
// Create test input with 3 short texts separated by the separator
const texts = [
'Hallo Max Mustermann aus Berlin',
'Liebe Anna Schmidt aus München',
'Sehr geehrter Tom Weber aus Hamburg'
];
const inputText = texts.join(separator.value);
console.log('\nInput:');
console.log(inputText.substring(0, 150));
console.log(`\nSeparator appears: ${(inputText.match(new RegExp(separator.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length} times`);
try {
const result = await callScriptalizer(inputText);
console.log('\nOutput (first 200 chars):');
console.log(result.outputText.substring(0, 200) + '...');
// Check if separator survived
const separatorRegex = new RegExp(separator.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const separatorCount = (result.outputText.match(separatorRegex) || []).length;
console.log(`\nSeparator found in output: ${separatorCount} times`);
if (separatorCount === 2) {
console.log('✅ SUCCESS: Separator survived!');
// Try to split
const split = result.outputText.split(separator.value);
console.log(`\nSplit result: ${split.length} parts`);
if (split.length === 3) {
console.log('✅ PERFECT: Can split into 3 parts!');
console.log('\nPart 1 (first 50 chars):', split[0].substring(0, 50) + '...');
console.log('Part 2 (first 50 chars):', split[1].substring(0, 50) + '...');
console.log('Part 3 (first 50 chars):', split[2].substring(0, 50) + '...');
return { separator: separator.name, value: separator.value, success: true, split: true };
} else {
console.log('⚠️ WARNING: Split count mismatch');
return { separator: separator.name, value: separator.value, success: true, split: false };
}
} else {
console.log('❌ FAILED: Separator was modified or not found');
return { separator: separator.name, value: separator.value, success: false, found: separatorCount };
}
} catch (err) {
console.log(`\n❌ ERROR: ${err.message}`);
return { separator: separator.name, value: separator.value, success: false, error: err.message };
}
}
async function runTests() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Scriptalizer Separator Test Suite ║');
console.log('║ Testing which separator survives API processing ║');
console.log('╚════════════════════════════════════════════════════════════╝');
const results = [];
for (const separator of SEPARATORS) {
const result = await testSeparator(separator);
results.push(result);
// Wait 2 seconds between tests to avoid rate limiting
if (separator !== SEPARATORS[SEPARATORS.length - 1]) {
console.log('\nWaiting 2 seconds before next test...');
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
// Summary
console.log('\n\n' + '═'.repeat(60));
console.log('SUMMARY');
console.log('═'.repeat(60));
const successful = results.filter(r => r.success && r.split);
const partial = results.filter(r => r.success && !r.split);
const failed = results.filter(r => !r.success);
console.log('\n✅ Fully Working Separators (can split):');
if (successful.length === 0) {
console.log(' None');
} else {
successful.forEach(r => console.log(` - ${r.separator} (${JSON.stringify(r.value)})`));
}
console.log('\n⚠ Partially Working Separators (found but cannot split):');
if (partial.length === 0) {
console.log(' None');
} else {
partial.forEach(r => console.log(` - ${r.separator}`));
}
console.log('\n❌ Failed Separators:');
if (failed.length === 0) {
console.log(' None');
} else {
failed.forEach(r => console.log(` - ${r.separator} (${r.error || 'modified by API'})`));
}
console.log('\n' + '═'.repeat(60));
if (successful.length > 0) {
console.log(`\n🎉 RECOMMENDATION: Use "${successful[0].separator}" as separator`);
console.log(` Separator value: ${JSON.stringify(successful[0].value)}`);
} else if (partial.length > 0) {
console.log(`\n⚠️ RECOMMENDATION: Use "${partial[0].separator}" but verify split logic`);
} else {
console.log('\n❌ No separator worked - need to use individual API calls');
}
}
// Run tests
runTests().catch(console.error);

View File

@@ -0,0 +1,12 @@
{
"sessionId": "tilda-test-demo",
"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": {}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"sessionId": "variation-test",
"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 und lebendiger.\n\nVielen Dank für Ihr Vertrauen!",
"placeholders": {}
}
]
}