Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
This commit is contained in:
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 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 / CI
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
|
||||||
|
# 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
|
||||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Directus (Datenquelle für Aufträge)
|
||||||
|
# Token = statischer Token eines Nutzers in der Rolle "Service"
|
||||||
|
DIRECTUS_URL=https://admin.skrift.de
|
||||||
|
DIRECTUS_TOKEN=your-directus-service-token
|
||||||
39
.gitea/workflows/deploy.yml
Normal file
39
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Intern klonen (NAT-Hairpin: die öffentliche Domain ist aus dem
|
||||||
|
# Job-Container heraus nicht erreichbar).
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: http://gitea:3000
|
||||||
|
|
||||||
|
# Kein separater Build/Test-Schritt: der Docker-Build (npm install) ist
|
||||||
|
# der Build. Falls später Tests dazukommen: hier VOR dem Deploy einfügen.
|
||||||
|
|
||||||
|
- name: Tag ermitteln
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: .env aus Secret erzeugen
|
||||||
|
env:
|
||||||
|
DOTENV: ${{ secrets.DOTENV }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOTENV" ]; then
|
||||||
|
echo "Secret DOTENV ist leer oder nicht gesetzt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
echo "$DOTENV" > .env
|
||||||
|
|
||||||
|
- name: Deployen
|
||||||
|
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180
|
||||||
|
|
||||||
|
- name: Alte Layer aufräumen
|
||||||
|
run: docker image prune -f
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
cache/
|
||||||
|
output/
|
||||||
|
*.log
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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"]
|
||||||
198
HANDSCHRIFT_VARIATIONEN.md
Normal file
198
HANDSCHRIFT_VARIATIONEN.md
Normal 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 ✅
|
||||||
461
README.md
Normal file
461
README.md
Normal 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
|
||||||
331
TILDA_API_BEISPIEL.md
Normal file
331
TILDA_API_BEISPIEL.md
Normal 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
|
||||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Auto-Deploy per Tag-Push – siehe DEPLOY.md. Fester Projekt-/Container-Name,
|
||||||
|
# Image mit Versionstag (Rollback ohne Rebuild), kein Port-Mapping (NPM proxyt
|
||||||
|
# intern über den Container-Namen).
|
||||||
|
name: skrift-backend
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: skrift-backend:${IMAGE_TAG:-latest}
|
||||||
|
build: .
|
||||||
|
container_name: skrift-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
# Betriebs-Defaults inline; Secrets/Config kommen aus .env (Secret DOTENV).
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=4000
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
# Fonts sind ins Image gebacken (COPY im Dockerfile) – kein Bind nötig.
|
||||||
|
- skrift-backend-cache:/app/cache
|
||||||
|
# Gemeinsamer Ausgabe-/SFTP-Ordner: ABSOLUTER Host-Pfad, bewusst KEIN named
|
||||||
|
# volume – Directus liest ihn (ro) und er ist das SFTP-Ziel der Produktion.
|
||||||
|
- /var/skrift-output:/app/output
|
||||||
|
networks:
|
||||||
|
- nginx-proxy-manager_default
|
||||||
|
# Gate für `--wait`. /health liefert 503, wenn Scriptalizer-Key fehlt oder
|
||||||
|
# der Output-Ordner nicht schreibbar ist – dann bricht der Deploy bewusst ab.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:4000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx-proxy-manager_default:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Vorschau-Cache (regenerierbar) mit festem Namen, damit er nicht am
|
||||||
|
# Projektnamen hängt.
|
||||||
|
skrift-backend-cache:
|
||||||
|
name: skrift-backend-cache
|
||||||
2224
fonts/alva.svg
Normal file
2224
fonts/alva.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 152 KiB |
2033
fonts/ellie.svg
Normal file
2033
fonts/ellie.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 149 KiB |
2319
fonts/tilda.svg
Normal file
2319
fonts/tilda.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 152 KiB |
1624
package-lock.json
generated
Normal file
1624
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/api/controllers/charge-controller.js
Normal file
69
src/api/controllers/charge-controller.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Charge-Controller – Sammel-PDF für API-Chargen.
|
||||||
|
*
|
||||||
|
* Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B.
|
||||||
|
* Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem
|
||||||
|
* Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit
|
||||||
|
* sie zur Kuvert-Reihenfolge im Plotter passt.
|
||||||
|
*/
|
||||||
|
const { PDFDocument } = require('pdf-lib');
|
||||||
|
const directus = require('../../services/directus-service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/charge/merge-pdf
|
||||||
|
* Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt)
|
||||||
|
*/
|
||||||
|
async function mergePdf(req, res, next) {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await directus.fetchOrdersMinimal(ids);
|
||||||
|
if (!orders || !orders.length) {
|
||||||
|
return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert).
|
||||||
|
const fileIds = [];
|
||||||
|
for (const o of orders) {
|
||||||
|
const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : [];
|
||||||
|
if (sf.length) fileIds.push(...sf);
|
||||||
|
else if (o.source_file) fileIds.push(o.source_file);
|
||||||
|
}
|
||||||
|
if (!fileIds.length) {
|
||||||
|
return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = await PDFDocument.create();
|
||||||
|
let ok = 0;
|
||||||
|
const skipped = [];
|
||||||
|
for (const fid of fileIds) {
|
||||||
|
try {
|
||||||
|
const buf = await directus.fetchAssetBuffer(fid);
|
||||||
|
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
|
||||||
|
const pages = await merged.copyPages(src, src.getPageIndices());
|
||||||
|
pages.forEach((p) => merged.addPage(p));
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
skipped.push(fid);
|
||||||
|
console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await merged.save();
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
|
||||||
|
res.setHeader('X-Skrift-Merged', String(ok));
|
||||||
|
if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length));
|
||||||
|
return res.status(200).send(Buffer.from(bytes));
|
||||||
|
} catch (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mergePdf };
|
||||||
225
src/api/controllers/directus-controller.js
Normal file
225
src/api/controllers/directus-controller.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Generierung auf Basis eines Directus-Auftrags.
|
||||||
|
*
|
||||||
|
* Ablauf: Directus meldet "Auftrag X ist bezahlt" → dieser Endpunkt holt den
|
||||||
|
* Auftrag samt Empfängerzeilen aus Directus, baut daraus die Dokumentliste für
|
||||||
|
* die bestehende Pipeline (runGeneration) und schreibt Ergebnis + Status zurück.
|
||||||
|
*
|
||||||
|
* Antwortet sofort mit 202 – die Generierung läuft im Hintergrund weiter, weil
|
||||||
|
* sie je nach Menge deutlich länger dauern kann als ein HTTP-Timeout erlaubt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { runGeneration } = require('./order-controller');
|
||||||
|
const directus = require('../../services/directus-service');
|
||||||
|
const { countLetterPages } = require('../../lib/svg-generator');
|
||||||
|
const { replacePlaceholders } = require('../../services/placeholder-service');
|
||||||
|
|
||||||
|
// Directus-Formatschlüssel → Backend-Formate
|
||||||
|
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||||
|
const ENVELOPE_FORMAT = { c6: 'c6', dinlang: 'din_lang' };
|
||||||
|
|
||||||
|
/** Baut den Adressblock für einen Umschlag aus einer Empfängerzeile. */
|
||||||
|
function addressText(entry) {
|
||||||
|
if (entry.free_text) return String(entry.free_text).trim();
|
||||||
|
const name = [entry.first_name, entry.last_name].filter(Boolean).join(' ');
|
||||||
|
const street = [entry.street, entry.house_no].filter(Boolean).join(' ');
|
||||||
|
const city = [entry.zip, entry.city].filter(Boolean).join(' ');
|
||||||
|
const isForeign = entry.country && !/^(de|deutschland|germany)$/i.test(String(entry.country).trim());
|
||||||
|
return [name, street, city, isForeign ? entry.country : null]
|
||||||
|
.filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platzhalterwerte einer Zeile (strukturierte Felder + freie Platzhalter). */
|
||||||
|
function placeholdersOf(entry) {
|
||||||
|
return {
|
||||||
|
Anrede: entry.salutation || '',
|
||||||
|
Vorname: entry.first_name || '',
|
||||||
|
Nachname: entry.last_name || '',
|
||||||
|
Strasse: [entry.street, entry.house_no].filter(Boolean).join(' '),
|
||||||
|
PLZ: entry.zip || '',
|
||||||
|
Ort: entry.city || '',
|
||||||
|
Land: entry.country || '',
|
||||||
|
Briefnummer: entry.letter_number ?? '',
|
||||||
|
...(entry.placeholders && typeof entry.placeholders === 'object' ? entry.placeholders : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
|
||||||
|
function buildDocuments(order, entries) {
|
||||||
|
const font = order.font || 'tilda';
|
||||||
|
// Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
|
||||||
|
const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
|
||||||
|
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
|
||||||
|
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
|
||||||
|
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
|
||||||
|
// Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom
|
||||||
|
// Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon.
|
||||||
|
const istDatei = order.product?.input_mode === 'datei';
|
||||||
|
// Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten)
|
||||||
|
// Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag.
|
||||||
|
const istKuvertOnly = order.product?.type === 'envelope';
|
||||||
|
// Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert.
|
||||||
|
const realistisch = order.realistic !== false;
|
||||||
|
// Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard.
|
||||||
|
const fontScaleLetter = Number(order.font_scale_letter) || 1;
|
||||||
|
const fontScaleEnv = Number(order.font_scale_envelope) || 1;
|
||||||
|
// Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung.
|
||||||
|
const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null;
|
||||||
|
const sigMap = sigManual?.signatures || {};
|
||||||
|
const sigOff = sigManual?.offsets || {};
|
||||||
|
// Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt.
|
||||||
|
const istMehrseitig = !!order.multi_page;
|
||||||
|
|
||||||
|
const documents = [];
|
||||||
|
|
||||||
|
entries.forEach((entry, i) => {
|
||||||
|
const placeholders = placeholdersOf(entry);
|
||||||
|
// Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu
|
||||||
|
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
|
||||||
|
const nr = Number(entry.letter_number) || (i + 1);
|
||||||
|
|
||||||
|
if (!istDatei && !istKuvertOnly) {
|
||||||
|
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext.
|
||||||
|
const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || '');
|
||||||
|
documents.push({
|
||||||
|
index: nr,
|
||||||
|
type: 'letter',
|
||||||
|
text: briefText,
|
||||||
|
format: letterFormat,
|
||||||
|
font,
|
||||||
|
placeholders,
|
||||||
|
fontScale: fontScaleLetter,
|
||||||
|
signatures: sigMap,
|
||||||
|
signatureOffsets: sigOff,
|
||||||
|
multiPage: istMehrseitig,
|
||||||
|
realisticHandwriting: realistisch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsEnvelope) {
|
||||||
|
documents.push({
|
||||||
|
index: nr,
|
||||||
|
type: 'envelope',
|
||||||
|
envelopeType,
|
||||||
|
text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
|
||||||
|
format: envFormat,
|
||||||
|
font,
|
||||||
|
placeholders,
|
||||||
|
fontScale: fontScaleEnv,
|
||||||
|
realisticHandwriting: realistisch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { documents, letterFormat, envFormat, wantsEnvelope };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/from-directus
|
||||||
|
* Body: { orderId }
|
||||||
|
*/
|
||||||
|
async function generateFromDirectus(req, res, next) {
|
||||||
|
const { orderId } = req.body || {};
|
||||||
|
if (!orderId) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', message: 'orderId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let order;
|
||||||
|
try {
|
||||||
|
const loaded = await directus.fetchOrder(orderId);
|
||||||
|
order = loaded.order;
|
||||||
|
|
||||||
|
if (!loaded.entries.length) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid order',
|
||||||
|
message: `Auftrag ${order.order_number} hat keine Empfängerzeilen.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort quittieren – der Rest läuft im Hintergrund.
|
||||||
|
res.status(202).json({
|
||||||
|
accepted: true,
|
||||||
|
orderId,
|
||||||
|
orderNumber: order.order_number,
|
||||||
|
documents: loaded.entries.length * (order.needs_envelope ? 2 : 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
|
||||||
|
|
||||||
|
// Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen.
|
||||||
|
if (!documents.length) {
|
||||||
|
console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
|
||||||
|
|
||||||
|
const result = await runGeneration({
|
||||||
|
orderNumber: order.order_number,
|
||||||
|
letters: documents,
|
||||||
|
metadata: {
|
||||||
|
letterFormat,
|
||||||
|
envelopeFormat: wantsEnvelope ? envFormat : null,
|
||||||
|
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
|
||||||
|
directusOrderId: orderId,
|
||||||
|
customerEmail: order.customer_email,
|
||||||
|
// Vorschau-Session → gezeigte Dokumente 1:1 übernehmen.
|
||||||
|
previewSession: order.preview_session || null,
|
||||||
|
// Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen.
|
||||||
|
rawText: order.text_template || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await directus.updateOrder(orderId, {
|
||||||
|
status: 'in_queue',
|
||||||
|
artifact_path: result.outputPath,
|
||||||
|
});
|
||||||
|
await directus.addStatusHistory(orderId, 'in_queue',
|
||||||
|
`${result.letters.length} Schriftstücke, ${result.envelopes.length} Umschläge generiert.`);
|
||||||
|
|
||||||
|
console.log(`[Directus] ${order.order_number} fertig → in_queue`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Directus] Generierung fehlgeschlagen:', err.message);
|
||||||
|
|
||||||
|
// Fehler in Directus sichtbar machen, statt still zu scheitern.
|
||||||
|
if (order?.id || orderId) {
|
||||||
|
try {
|
||||||
|
await directus.addStatusHistory(orderId, 'fehler', `Generierung fehlgeschlagen: ${err.message}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Directus] Konnte Fehler nicht zurückschreiben:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort wurde ggf. schon gesendet (202) – dann nur noch loggen.
|
||||||
|
if (!res.headersSent) {
|
||||||
|
if (err.status) return res.status(err.status).json({ error: 'Generation failed', message: err.message });
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/pagecount
|
||||||
|
* Body: { text, format, fontScale, entries: [{ placeholders }] }
|
||||||
|
* Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG).
|
||||||
|
*/
|
||||||
|
function pageCounts(req, res) {
|
||||||
|
const { text, format, fontScale, entries } = req.body || {};
|
||||||
|
if (!text || !String(text).trim()) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', message: 'text is required' });
|
||||||
|
}
|
||||||
|
const fmt = format || 'a4';
|
||||||
|
const scale = Number(fontScale) || 1;
|
||||||
|
const list = Array.isArray(entries) && entries.length ? entries : [{}];
|
||||||
|
const counts = list.map((e, i) => {
|
||||||
|
const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) };
|
||||||
|
let pages = 1;
|
||||||
|
try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); }
|
||||||
|
catch { pages = 1; }
|
||||||
|
return { index: i + 1, pages };
|
||||||
|
});
|
||||||
|
return res.json({ counts });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateFromDirectus, pageCounts };
|
||||||
601
src/api/controllers/order-controller.js
Normal file
601
src/api/controllers/order-controller.js
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
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, generateLetterPages } = 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 runGeneration({ orderNumber, letters, metadata }) {
|
||||||
|
{
|
||||||
|
// Validation
|
||||||
|
if (!orderNumber) {
|
||||||
|
const e = new Error('orderNumber is required');
|
||||||
|
e.status = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!letters || !Array.isArray(letters) || letters.length === 0) {
|
||||||
|
const e = new Error('Letters array is required and must not be empty');
|
||||||
|
e.status = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
// Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge).
|
||||||
|
signatures: { ...(letter.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+\]\]|\[\[seitenumbruch\]\]|[&|])/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);
|
||||||
|
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
|
||||||
|
e.status = 500;
|
||||||
|
e.label = 'Scriptalization failed';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Generate SVG files - separate letters and envelopes
|
||||||
|
const generatedLetters = [];
|
||||||
|
const generatedEnvelopes = [];
|
||||||
|
// Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1
|
||||||
|
// aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte
|
||||||
|
// Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift).
|
||||||
|
const previewSession = (metadata || {}).previewSession;
|
||||||
|
const previewsRoot = config.paths.previews;
|
||||||
|
|
||||||
|
for (let i = 0; i < processedLetters.length; i++) {
|
||||||
|
const letter = processedLetters[i];
|
||||||
|
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||||
|
const isEnv = letter.type === 'envelope';
|
||||||
|
const pad = String(absoluteIndex).padStart(3, '0');
|
||||||
|
|
||||||
|
// Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N).
|
||||||
|
if (!isEnv && letter.multiPage) {
|
||||||
|
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
||||||
|
let pages;
|
||||||
|
try {
|
||||||
|
({ pages } = generateLetterPages(scriptalizedText, letter.format, {
|
||||||
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
|
||||||
|
e.status = 500; e.label = 'SVG generation failed'; throw e;
|
||||||
|
}
|
||||||
|
// Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern –
|
||||||
|
// nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite.
|
||||||
|
let alleInVorschau = false;
|
||||||
|
if (previewSession) {
|
||||||
|
alleInVorschau = true;
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); }
|
||||||
|
catch { alleInVorschau = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
const fn = `letter_${pad}_p${p + 1}.svg`;
|
||||||
|
const fp = path.join(outputDir, fn);
|
||||||
|
if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp);
|
||||||
|
else await fs.writeFile(fp, pages[p], 'utf8');
|
||||||
|
generatedLetters.push(fn);
|
||||||
|
}
|
||||||
|
console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.svg`;
|
||||||
|
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
|
||||||
|
|
||||||
|
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
|
||||||
|
// Vorschau-Datei existiert.
|
||||||
|
if (previewSession) {
|
||||||
|
const cached = path.join(previewsRoot, previewSession, filename);
|
||||||
|
try {
|
||||||
|
await fs.access(cached);
|
||||||
|
await fs.copyFile(cached, filepath);
|
||||||
|
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
|
||||||
|
console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`);
|
||||||
|
continue;
|
||||||
|
} catch { /* keine Vorschau-Datei → normal rendern */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
||||||
|
let svgResult;
|
||||||
|
try {
|
||||||
|
if (isEnv) {
|
||||||
|
svgResult = generateEnvelopeSVG(
|
||||||
|
scriptalizedText, letter.format, letter.envelopeType,
|
||||||
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
svgResult = generateLetterSVG(
|
||||||
|
scriptalizedText, letter.format,
|
||||||
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
|
||||||
|
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
|
||||||
|
e.status = 500;
|
||||||
|
e.label = 'SVG generation failed';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
||||||
|
(isEnv ? generatedEnvelopes : 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 = 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');
|
||||||
|
|
||||||
|
// Eingegebenen Text als reine Textdatei mitablegen (falls übergeben).
|
||||||
|
if (meta.rawText) {
|
||||||
|
try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); }
|
||||||
|
catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderNumber,
|
||||||
|
outputPath: outputDir,
|
||||||
|
letters: generatedLetters,
|
||||||
|
envelopes: generatedEnvelopes,
|
||||||
|
csvFiles,
|
||||||
|
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
|
||||||
|
timestamp: orderMetadata.generatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/generate
|
||||||
|
* HTTP-Wrapper um runGeneration – Verhalten unverändert.
|
||||||
|
*/
|
||||||
|
async function generateOrder(req, res, next) {
|
||||||
|
try {
|
||||||
|
const { orderNumber, letters, metadata } = req.body;
|
||||||
|
const result = await runGeneration({ orderNumber, letters, metadata });
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status) {
|
||||||
|
return res.status(err.status).json({
|
||||||
|
error: err.label || 'Invalid request',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
runGeneration,
|
||||||
|
uploadMotif,
|
||||||
|
uploadOrderFile,
|
||||||
|
registerOrder,
|
||||||
|
};
|
||||||
363
src/api/controllers/preview-controller.js
Normal file
363
src/api/controllers/preview-controller.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
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,
|
||||||
|
generateLetterPages,
|
||||||
|
} = 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,
|
||||||
|
// Signaturen aus Platzhaltern UND direkt mitgeschickte (manuelle Aufträge).
|
||||||
|
signatures: { ...(letter.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+\]\]|\[\[seitenumbruch\]\]|[&|])/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;
|
||||||
|
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||||
|
const pad = String(absoluteIndex).padStart(3, "0");
|
||||||
|
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (letter.type === "envelope") {
|
||||||
|
const svgResult = generateEnvelopeSVG(
|
||||||
|
scriptalizedText, letter.format, letter.envelopeType,
|
||||||
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
|
||||||
|
);
|
||||||
|
if (svgResult.overflow) hasOverflow = true;
|
||||||
|
const filename = `envelope_${pad}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
|
||||||
|
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
|
||||||
|
recipientName: null,
|
||||||
|
});
|
||||||
|
} else if (letter.multiPage) {
|
||||||
|
// Mehrseitiger Brief: Text fließt über N Seiten – kein Überlauf-Fehler.
|
||||||
|
const { pages } = generateLetterPages(scriptalizedText, letter.format, {
|
||||||
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
|
||||||
|
});
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
const filename = `letter_${pad}_p${p + 1}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), pages[p], "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, page: p + 1, pageCount: pages.length, filename,
|
||||||
|
url: `/api/preview/${finalSessionId}/${filename}`, recipientName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const svgResult = generateLetterSVG(scriptalizedText, letter.format, {
|
||||||
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false,
|
||||||
|
signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {},
|
||||||
|
fontScale: letter.fontScale,
|
||||||
|
});
|
||||||
|
if (svgResult.overflow) {
|
||||||
|
hasOverflow = true;
|
||||||
|
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
|
||||||
|
}
|
||||||
|
const filename = `letter_${pad}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
|
||||||
|
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
|
||||||
|
recipientName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
72
src/api/middleware/auth.js
Normal file
72
src/api/middleware/auth.js
Normal 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,
|
||||||
|
};
|
||||||
13
src/api/middleware/error-handler.js
Normal file
13
src/api/middleware/error-handler.js
Normal 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 })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
53
src/api/middleware/rate-limiter.js
Normal file
53
src/api/middleware/rate-limiter.js
Normal 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();
|
||||||
|
};
|
||||||
14
src/api/middleware/request-logger.js
Normal file
14
src/api/middleware/request-logger.js
Normal 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();
|
||||||
|
};
|
||||||
13
src/api/routes/charge-routes.js
Normal file
13
src/api/routes/charge-routes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const chargeController = require('../controllers/charge-controller');
|
||||||
|
const { authenticateApiToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/charge/merge-pdf
|
||||||
|
* Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck).
|
||||||
|
* Body: { orderIds: (number|string)[] }
|
||||||
|
*/
|
||||||
|
router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
34
src/api/routes/health-routes.js
Normal file
34
src/api/routes/health-routes.js
Normal 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;
|
||||||
154
src/api/routes/order-routes.js
Normal file
154
src/api/routes/order-routes.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const orderController = require('../controllers/order-controller');
|
||||||
|
const directusController = require('../controllers/directus-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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/from-directus
|
||||||
|
* Generiert einen Auftrag, dessen Daten in Directus liegen.
|
||||||
|
*
|
||||||
|
* Request body: { orderId: string|number } (ID des Directus-Auftrags)
|
||||||
|
*
|
||||||
|
* Antwortet mit 202 (accepted); die Generierung läuft im Hintergrund und
|
||||||
|
* schreibt Status + artifact_path selbstständig nach Directus zurück.
|
||||||
|
*/
|
||||||
|
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/pagecount
|
||||||
|
* Seitenzahl je Dokument (mehrseitig) – ohne Scriptalizer/SVG, nur Umbruch-Zählung.
|
||||||
|
* Body: { text, format, fontScale, entries: [{ placeholders }] }
|
||||||
|
*/
|
||||||
|
router.post('/pagecount', authenticateApiToken, directusController.pageCounts);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
47
src/api/routes/preview-routes.js
Normal file
47
src/api/routes/preview-routes.js
Normal 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;
|
||||||
59
src/config/index.js
Normal file
59
src/config/index.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
// Directus ist die Datenquelle für Aufträge (Rolle "Service"-Token).
|
||||||
|
directus: {
|
||||||
|
url: process.env.DIRECTUS_URL || '',
|
||||||
|
token: process.env.DIRECTUS_TOKEN || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
paypal: {
|
||||||
|
clientId: process.env.PAYPAL_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
||||||
|
// 'sandbox' oder 'live'
|
||||||
|
environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox'
|
||||||
|
}
|
||||||
|
};
|
||||||
146
src/lib/page-layout.js
Normal file
146
src/lib/page-layout.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
const PX_PER_MM = 3.78; // 96dpi
|
||||||
|
const FONT_SIZE_PX = 26;
|
||||||
|
|
||||||
|
// ── Formate: Defaults (Fallback) + datengetriebener Cache ───────────────────
|
||||||
|
// Die Werte kommen zentral aus Directus (Collection render_formats) und werden
|
||||||
|
// im Speicher gehalten (stündlich / manuell aufgefrischt via applyRenderFormats).
|
||||||
|
// Ist Directus nicht erreichbar oder ein Format nicht hinterlegt, greifen die
|
||||||
|
// eingebauten Defaults – die Generierung läuft also immer.
|
||||||
|
const DEFAULT_LINE_LIMITS = { a4: 27, a6p: 14, a6l: 11 };
|
||||||
|
const DEFAULT_FORMAT_LAYOUTS = {
|
||||||
|
a4: { marginTopMm: 25, marginLeftMm: 20, lineHeightFactor: 1.35 },
|
||||||
|
a6p: { marginTopMm: 8, marginLeftMm: 8, lineHeightFactor: 1.3 },
|
||||||
|
a6l: { marginTopMm: 8, marginLeftMm: 8, marginRightMm: 14, lineHeightFactor: 1.2 },
|
||||||
|
din_lang: { marginTopMm: 50, marginLeftMm: 10, lineHeightFactor: 1.2 },
|
||||||
|
c6: { marginTopMm: 60, marginLeftMm: 10, lineHeightFactor: 1.2 },
|
||||||
|
};
|
||||||
|
const DEFAULT_PAGE_FORMATS = {
|
||||||
|
a4: { widthMm: 210, heightMm: 297 },
|
||||||
|
a6p: { widthMm: 105, heightMm: 148 },
|
||||||
|
a6l: { widthMm: 148, heightMm: 105 },
|
||||||
|
din_lang: { widthMm: 220, heightMm: 110 },
|
||||||
|
c6: { widthMm: 162, heightMm: 114 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live-Cache (wird von applyRenderFormats ersetzt). Start = Defaults.
|
||||||
|
let LINE_LIMITS = { ...DEFAULT_LINE_LIMITS };
|
||||||
|
let FORMAT_LAYOUTS = { ...DEFAULT_FORMAT_LAYOUTS };
|
||||||
|
let PAGE_FORMATS = { ...DEFAULT_PAGE_FORMATS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt Format-Definitionen aus Directus (render_formats) in den Cache.
|
||||||
|
* Baut immer auf den Defaults auf, damit Teilangaben nichts kaputt machen.
|
||||||
|
* rows: [{ key, page_width_mm, page_height_mm, margin_top_mm, margin_left_mm,
|
||||||
|
* margin_right_mm, line_height_factor, line_limit }]
|
||||||
|
*/
|
||||||
|
function applyRenderFormats(rows) {
|
||||||
|
const pf = { ...DEFAULT_PAGE_FORMATS };
|
||||||
|
const fl = { ...DEFAULT_FORMAT_LAYOUTS };
|
||||||
|
const ll = { ...DEFAULT_LINE_LIMITS };
|
||||||
|
for (const r of Array.isArray(rows) ? rows : []) {
|
||||||
|
const k = String(r.key || '').toLowerCase();
|
||||||
|
if (!k) continue;
|
||||||
|
if (r.page_width_mm && r.page_height_mm) {
|
||||||
|
pf[k] = { widthMm: Number(r.page_width_mm), heightMm: Number(r.page_height_mm) };
|
||||||
|
}
|
||||||
|
const alt = fl[k] || {};
|
||||||
|
const layout = {
|
||||||
|
marginTopMm: Number(r.margin_top_mm ?? alt.marginTopMm ?? 25),
|
||||||
|
marginLeftMm: Number(r.margin_left_mm ?? alt.marginLeftMm ?? 20),
|
||||||
|
lineHeightFactor: Number(r.line_height_factor ?? alt.lineHeightFactor ?? 1.35),
|
||||||
|
};
|
||||||
|
const mr = r.margin_right_mm ?? alt.marginRightMm;
|
||||||
|
if (mr != null && mr !== '') layout.marginRightMm = Number(mr);
|
||||||
|
fl[k] = layout;
|
||||||
|
if (r.line_limit != null && r.line_limit !== '') ll[k] = Number(r.line_limit);
|
||||||
|
}
|
||||||
|
PAGE_FORMATS = pf;
|
||||||
|
FORMAT_LAYOUTS = fl;
|
||||||
|
LINE_LIMITS = ll;
|
||||||
|
signaledMisses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// mm in Pixel umrechnen
|
||||||
|
function mmToPx(mm) {
|
||||||
|
return mm * PX_PER_MM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh bei unbekanntem Format ──────────────────────────────────────────
|
||||||
|
// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout
|
||||||
|
// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt).
|
||||||
|
// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die
|
||||||
|
// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der
|
||||||
|
// Default – der nächste Aufruf hat das Format.
|
||||||
|
let missHandler = null;
|
||||||
|
const signaledMisses = new Set();
|
||||||
|
function setFormatMissHandler(fn) { missHandler = fn; }
|
||||||
|
function signalMiss(key) {
|
||||||
|
const k = String(key || '').toLowerCase();
|
||||||
|
if (!missHandler || !k || signaledMisses.has(k)) return;
|
||||||
|
signaledMisses.add(k);
|
||||||
|
try { missHandler(k); } catch { /* egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout für Format holen
|
||||||
|
function getLayoutForFormat(format) {
|
||||||
|
const normalizedFormat = String(format).toLowerCase();
|
||||||
|
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
|
||||||
|
if (!layoutConfig) signalMiss(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();
|
||||||
|
if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat);
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeilenlimit für Format holen
|
||||||
|
function getLineLimit(format) {
|
||||||
|
const normalizedFormat = String(format).toLowerCase();
|
||||||
|
if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat);
|
||||||
|
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PX_PER_MM,
|
||||||
|
FONT_SIZE_PX,
|
||||||
|
mmToPx,
|
||||||
|
getLayoutForFormat,
|
||||||
|
getPageDimensions,
|
||||||
|
getLineLimit,
|
||||||
|
escapeXml,
|
||||||
|
applyRenderFormats,
|
||||||
|
setFormatMissHandler,
|
||||||
|
};
|
||||||
318
src/lib/svg-font-engine.js
Normal file
318
src/lib/svg-font-engine.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
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. < → <, & → &, " → ")
|
||||||
|
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(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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).
|
||||||
|
* maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele
|
||||||
|
* Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert.
|
||||||
|
*/
|
||||||
|
function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) {
|
||||||
|
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.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
|
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) {
|
||||||
|
// Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts).
|
||||||
|
// Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als
|
||||||
|
// "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile.
|
||||||
|
// Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout.
|
||||||
|
const lead = para.match(new RegExp(`^${TAB_MARKER}+`));
|
||||||
|
if (lead) {
|
||||||
|
const rest = para.slice(lead[0].length);
|
||||||
|
if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) {
|
||||||
|
allLines.push(para);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
maxWidthPx = Infinity,
|
||||||
|
}) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken;
|
||||||
|
// würde der restliche Zeileninhalt dort über den rechten Rand laufen, die
|
||||||
|
// Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts,
|
||||||
|
// ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist.
|
||||||
|
let startIdx = 0;
|
||||||
|
if (line[0] === TAB_MARKER) {
|
||||||
|
let leadCount = 0;
|
||||||
|
while (line[leadCount] === TAB_MARKER) leadCount++;
|
||||||
|
let tx = 0;
|
||||||
|
for (let t = 0; t < leadCount; t++) {
|
||||||
|
tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
|
}
|
||||||
|
const rest = line.slice(leadCount);
|
||||||
|
const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx });
|
||||||
|
if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW);
|
||||||
|
x = startX + tx;
|
||||||
|
startIdx = leadCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startIdx; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
|
||||||
|
// Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt)
|
||||||
|
if (ch === TAB_MARKER) {
|
||||||
|
const relX = x - startX;
|
||||||
|
const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
|
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
|
||||||
|
};
|
||||||
461
src/lib/svg-generator.js
Normal file
461
src/lib/svg-generator.js
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
const {
|
||||||
|
FONT_SIZE_PX,
|
||||||
|
getLayoutForFormat,
|
||||||
|
getPageDimensions,
|
||||||
|
getLineLimit,
|
||||||
|
escapeXml,
|
||||||
|
mmToPx
|
||||||
|
} = require('./page-layout');
|
||||||
|
|
||||||
|
const {
|
||||||
|
layoutTextToPaths,
|
||||||
|
wrapTextToLinesByWidth,
|
||||||
|
measureTextWidthPx,
|
||||||
|
TAB_MARKER
|
||||||
|
} = 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 || {};
|
||||||
|
// Feinjustierung je Signatur: { signatur1: { x: mm, y: mm, scale: Faktor } }.
|
||||||
|
const signatureOffsets = options.signatureOffsets || {};
|
||||||
|
// Einseitig: Seitenumbruch-Marker gibt es hier nicht – als Zeilenumbruch behandeln,
|
||||||
|
// damit er nie wörtlich gedruckt wird (falls „Mehrseitig" nicht aktiv ist).
|
||||||
|
text = String(text || '').replace(/\[\[seitenumbruch\]\]/gi, '\n');
|
||||||
|
|
||||||
|
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
||||||
|
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
|
||||||
|
// Relative Schriftgröße (nur manuelle Aufträge setzen ≠ 1). Schrift UND Zeilenhöhe
|
||||||
|
// skalieren mit, damit sich Zeilen nicht überlappen. Default 1 = unverändert.
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstBaselineY = marginTopPx + fontSizePx;
|
||||||
|
const paths = layoutTextToPaths({
|
||||||
|
lines,
|
||||||
|
fontFileName: fontConfig.file,
|
||||||
|
fontSizePx,
|
||||||
|
startX: marginLeftPx,
|
||||||
|
startY: firstBaselineY,
|
||||||
|
lineHeightPx,
|
||||||
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ');
|
||||||
|
|
||||||
|
// Größere Schrift → weniger Zeilen passen aufs Blatt (Limit gegenläufig skalieren).
|
||||||
|
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
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 + fontSizePx;
|
||||||
|
let totalLineCount = 0;
|
||||||
|
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const paths = layoutTextToPaths({
|
||||||
|
lines,
|
||||||
|
fontFileName: fontConfig.file,
|
||||||
|
fontSizePx,
|
||||||
|
startX: marginLeftPx,
|
||||||
|
startY: currentY,
|
||||||
|
lineHeightPx,
|
||||||
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
// Feinjustierung: Versatz in mm + Größen-Faktor relativ zur Auto-Position.
|
||||||
|
const off = signatureOffsets[sigSeg.key] || {};
|
||||||
|
const dx = mmToPx(Number(off.x) || 0);
|
||||||
|
const dy = mmToPx(Number(off.y) || 0);
|
||||||
|
const sc = Number(off.scale) > 0 ? Number(off.scale) : 1;
|
||||||
|
const sigX = marginLeftPx + slotIdx * sigSlotW + dx;
|
||||||
|
const sigEl = buildSignatureElement(svgString, sigX, currentY + dy, sigScale * sc);
|
||||||
|
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: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
|
|
||||||
|
// Relative Schriftgröße (getrennt vom Brief einstellbar). Default 1 = unverändert.
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zentrierung berechnen
|
||||||
|
const totalTextHeight = lines.length * lineHeightPx;
|
||||||
|
startY = (heightPx - totalTextHeight) / 2 + fontSizePx;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
startX = marginLeftPx;
|
||||||
|
startY = marginTopPx + fontSizePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
const centeredX = (widthPx - lineWidth) / 2;
|
||||||
|
|
||||||
|
const linePaths = layoutTextToPaths({
|
||||||
|
lines: [line],
|
||||||
|
fontFileName: fontConfig.file,
|
||||||
|
fontSizePx,
|
||||||
|
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,
|
||||||
|
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 + fontSizePx
|
||||||
|
: marginTopPx + fontSizePx;
|
||||||
|
const available = heightPx - overflowStartY - fontSizePx * 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrseitiges Schriftstück: Der Text fließt automatisch über so viele Seiten,
|
||||||
|
* wie nötig (linesPerPage = Zeilenlimit des Formats). Ein erzwungener Umbruch ist
|
||||||
|
* über den Marker [[seitenumbruch]] möglich – jeder so getrennte Block beginnt auf
|
||||||
|
* einer neuen Seite. Gibt die Seiten in natürlicher Reihenfolge (Seite 1..N) zurück.
|
||||||
|
*
|
||||||
|
* Bewusst OHNE Signatur-Unterstützung (kommt als eigener Schritt). Tab-/Rechts-
|
||||||
|
* bündig-Logik und relative Schriftgröße greifen pro Seite wie beim Einzelbrief.
|
||||||
|
*/
|
||||||
|
function generateLetterPages(text, format, options = {}) {
|
||||||
|
const fontKey = normalizeFontKey(options.font);
|
||||||
|
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
||||||
|
|
||||||
|
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
||||||
|
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
|
// Erzwungene Seitenumbrüche zuerst; danach jeden Block nach Zeilen auto-fließen.
|
||||||
|
const bloecke = String(text || '').split(/\[\[seitenumbruch\]\]/gi);
|
||||||
|
const seitenZeilen = [];
|
||||||
|
for (const block of bloecke) {
|
||||||
|
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
|
||||||
|
for (let i = 0; i < lines.length; i += linesPerPage) {
|
||||||
|
seitenZeilen.push(lines.slice(i, i + linesPerPage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seitenZeilen.length === 0) seitenZeilen.push(['']);
|
||||||
|
|
||||||
|
// Leere Seiten verwerfen: enthält eine Seite kein sichtbares Zeichen (nur
|
||||||
|
// Leerzeichen, Tabs, Umbrüche), wird sie weder generiert noch gedruckt.
|
||||||
|
// Bleibt nichts übrig (komplett leerer Text), eine einzige Seite behalten.
|
||||||
|
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
|
||||||
|
let seiten = seitenZeilen.filter((lines) => !istLeer(lines));
|
||||||
|
if (seiten.length === 0) seiten = [['']];
|
||||||
|
|
||||||
|
const pages = seiten.map((lines) => {
|
||||||
|
const paths = layoutTextToPaths({
|
||||||
|
lines,
|
||||||
|
fontFileName: fontConfig.file,
|
||||||
|
fontSizePx,
|
||||||
|
startX: marginLeftPx,
|
||||||
|
startY: marginTopPx + fontSizePx,
|
||||||
|
lineHeightPx,
|
||||||
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
|
});
|
||||||
|
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 ');
|
||||||
|
return `<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();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pages, pageCount: pages.length, lineCount: seiten.reduce((a, l) => a + l.length, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt, wie viele Seiten ein Text ergäbe (gleiche Logik wie generateLetterPages,
|
||||||
|
* aber ohne SVG/Scriptalizer) – für die Seiten-Übersicht je Schriftstück.
|
||||||
|
*/
|
||||||
|
function countLetterPages(text, format, options = {}) {
|
||||||
|
const fontConfig = SVG_FONT_CONFIG[normalizeFontKey(options.font)];
|
||||||
|
const { widthPx } = getPageDimensions(format);
|
||||||
|
const { marginLeftPx, marginRightPx } = getLayoutForFormat(format);
|
||||||
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
|
const seitenZeilen = [];
|
||||||
|
for (const block of String(text || '').split(/\[\[seitenumbruch\]\]/gi)) {
|
||||||
|
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
|
||||||
|
for (let i = 0; i < lines.length; i += linesPerPage) seitenZeilen.push(lines.slice(i, i + linesPerPage));
|
||||||
|
}
|
||||||
|
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
|
||||||
|
const seiten = seitenZeilen.filter((lines) => !istLeer(lines));
|
||||||
|
return Math.max(1, seiten.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateLetterSVG,
|
||||||
|
generateEnvelopeSVG,
|
||||||
|
generateLetterPages,
|
||||||
|
countLetterPages
|
||||||
|
};
|
||||||
93
src/server.js
Normal file
93
src/server.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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 chargeRoutes = require('./api/routes/charge-routes');
|
||||||
|
const healthRoutes = require('./api/routes/health-routes');
|
||||||
|
|
||||||
|
const errorHandler = require('./api/middleware/error-handler');
|
||||||
|
const requestLogger = require('./api/middleware/request-logger');
|
||||||
|
|
||||||
|
const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout');
|
||||||
|
const { fetchRenderFormats } = require('./services/directus-service');
|
||||||
|
|
||||||
|
// Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start
|
||||||
|
// und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen).
|
||||||
|
let lastFormatRefresh = 0;
|
||||||
|
async function refreshRenderFormats() {
|
||||||
|
lastFormatRefresh = Date.now();
|
||||||
|
try {
|
||||||
|
const rows = await fetchRenderFormats();
|
||||||
|
applyRenderFormats(rows);
|
||||||
|
console.log(`[Formate] ${Array.isArray(rows) ? rows.length : 0} Format(e) aus Directus geladen.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Formate] Konnte Directus-Formate nicht laden (nutze Defaults): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein
|
||||||
|
// dauerhaft unbekanntes Format kein Dauerfeuer auslöst).
|
||||||
|
setFormatMissHandler((key) => {
|
||||||
|
if (Date.now() - lastFormatRefresh < 30 * 1000) return;
|
||||||
|
console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`);
|
||||||
|
refreshRenderFormats();
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors(config.cors));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/health', healthRoutes);
|
||||||
|
app.use('/api/preview', previewRoutes);
|
||||||
|
app.use('/api/order', orderRoutes);
|
||||||
|
app.use('/api/charge', chargeRoutes);
|
||||||
|
|
||||||
|
// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen".
|
||||||
|
app.post('/api/formats/refresh', async (req, res) => {
|
||||||
|
const token = config.auth && config.auth.apiToken;
|
||||||
|
if (token && req.headers['x-api-token'] !== token) {
|
||||||
|
return res.status(401).json({ error: 'API token required' });
|
||||||
|
}
|
||||||
|
await refreshRenderFormats();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
// Formate initial laden und stündlich auffrischen (zentral aus Directus).
|
||||||
|
refreshRenderFormats();
|
||||||
|
setInterval(refreshRenderFormats, 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
113
src/services/directus-service.js
Normal file
113
src/services/directus-service.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Directus-Client für das Backend.
|
||||||
|
*
|
||||||
|
* Directus ist die Datenquelle für Aufträge; dieses Backend bleibt die reine
|
||||||
|
* Generierungs-Engine. Zugriff über einen Service-Token (Rolle "Service").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
function requireConfig() {
|
||||||
|
if (!config.directus.url || !config.directus.token) {
|
||||||
|
const e = new Error('DIRECTUS_URL und DIRECTUS_TOKEN müssen gesetzt sein.');
|
||||||
|
e.status = 500;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function directusFetch(path, options = {}) {
|
||||||
|
requireConfig();
|
||||||
|
const res = await fetch(config.directus.url.replace(/\/$/, '') + path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${config.directus.token}`,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.errors?.[0]?.message || text || res.statusText;
|
||||||
|
const e = new Error(`Directus ${options.method || 'GET'} ${path}: ${msg}`);
|
||||||
|
e.status = res.status === 404 ? 404 : 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return data?.data ?? data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt einen Auftrag inkl. Empfängerzeilen, Produkt und Format. */
|
||||||
|
async function fetchOrder(orderId) {
|
||||||
|
const fields = [
|
||||||
|
'id', 'order_number', 'customer_email', 'font', 'status',
|
||||||
|
'needs_envelope', 'envelope_labeling', 'envelope_text', 'envelope_format',
|
||||||
|
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
|
||||||
|
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
|
||||||
|
'font_scale_letter', 'font_scale_envelope', 'signatures_manual', 'multi_page', 'realistic',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
|
||||||
|
if (!order) {
|
||||||
|
const e = new Error(`Auftrag ${orderId} nicht gefunden.`);
|
||||||
|
e.status = 404;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await directusFetch(
|
||||||
|
`/items/order_entries?filter[order][_eq]=${encodeURIComponent(orderId)}` +
|
||||||
|
`&sort=letter_number&limit=-1&fields=*`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { order, entries: entries || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt Status und Artefakt-Pfad zurück. */
|
||||||
|
async function updateOrder(orderId, patch) {
|
||||||
|
return directusFetch(`/items/orders/${encodeURIComponent(orderId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protokolliert einen Statuswechsel in der Timeline. */
|
||||||
|
async function addStatusHistory(orderId, status, note) {
|
||||||
|
return directusFetch('/items/status_history', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ order: orderId, status, note }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt mehrere Aufträge (nur die für den Charge-Sammeldruck nötigen Felder),
|
||||||
|
* sortiert nach Auftragsnummer. */
|
||||||
|
async function fetchOrdersMinimal(ids) {
|
||||||
|
const idList = ids.map((v) => encodeURIComponent(v)).join(',');
|
||||||
|
return directusFetch(
|
||||||
|
`/items/orders?filter[id][_in]=${idList}&limit=-1&sort=order_number` +
|
||||||
|
'&fields=id,order_number,source_files,source_file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt eine Directus-Datei (z. B. hochgeladenes Brief-PDF) als Buffer. */
|
||||||
|
async function fetchAssetBuffer(fileId) {
|
||||||
|
requireConfig();
|
||||||
|
const res = await fetch(config.directus.url.replace(/\/$/, '') + '/assets/' + encodeURIComponent(fileId), {
|
||||||
|
headers: { Authorization: `Bearer ${config.directus.token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = new Error(`Directus asset ${fileId}: ${res.status}`);
|
||||||
|
e.status = res.status === 404 ? 404 : 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */
|
||||||
|
async function fetchRenderFormats() {
|
||||||
|
return directusFetch(
|
||||||
|
'/items/render_formats?filter[active][_eq]=true&limit=-1' +
|
||||||
|
'&fields=key,page_width_mm,page_height_mm,margin_top_mm,margin_left_mm,margin_right_mm,line_height_factor,line_limit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer };
|
||||||
294
src/services/placeholder-service.js
Normal file
294
src/services/placeholder-service.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys in Auftrittsreihenfolge sammeln (NICHT alphabetisch sortieren – sonst
|
||||||
|
// stimmt die Spaltenreihenfolge nicht mit der Tabelle auf der Website überein).
|
||||||
|
const auftritt = [];
|
||||||
|
letterItems.forEach(letter => {
|
||||||
|
if (letter.placeholders) {
|
||||||
|
Object.keys(letter.placeholders).forEach(key => {
|
||||||
|
if (!auftritt.includes(key)) auftritt.push(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auftritt.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strukturierte Adressfelder zuerst (wie im Konfigurator), eigene Platzhalter
|
||||||
|
// danach in Auftrittsreihenfolge. Briefnummer steckt bereits in „BriefNr".
|
||||||
|
const BEVORZUGT = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land'];
|
||||||
|
const vorhanden = new Set(auftritt);
|
||||||
|
const geordnet = [
|
||||||
|
...BEVORZUGT.filter(k => vorhanden.has(k)),
|
||||||
|
...auftritt.filter(k => !BEVORZUGT.includes(k) && k !== 'Briefnummer'),
|
||||||
|
];
|
||||||
|
const keys = ['BriefNr', ...geordnet];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
230
src/services/scriptalizer-service.js
Normal file
230
src/services/scriptalizer-service.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user