Initiale Ablage der umsortierten Skrift-Programme

Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 12:21:08 +02:00
commit 0341c4eaea
90 changed files with 40401 additions and 0 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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