Files
skrift-backend/src/lib/svg-generator.js
s4luorth 3dfcdcd3b5
All checks were successful
Build & Deploy / deploy (push) Successful in 19s
Kuvert-Absender: groesser + prozentuale Groesse (envelope_sender_scale)
Absender-Grundgroesse = Empfaenger-Grundschrift (war 0.62x = zu klein), zusaetzlich
ueber envelope_sender_scale (50-200%) einstellbar. Durchgereicht via Generierung + Vorschau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 11:04:23 +02:00

513 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '';
// viewBox der Signatur lesen, damit beliebige Quell-SVGs (z.B. Illustrator-
// Export mit eigener viewBox) korrekt normiert werden statt fix auf den
// 600×160-Zeichen-Canvas. Ohne viewBox: Fallback auf den Canvas (Alt-Signaturen).
let minx = 0;
let miny = 0;
let vh = SIG_CANVAS_H;
const vbM = svgString.match(/viewBox\s*=\s*"([^"]+)"/i);
if (vbM) {
const p = vbM[1].trim().split(/[\s,]+/).map(Number);
if (p.length === 4 && p[2] > 0 && p[3] > 0) { minx = p[0]; miny = p[1]; vh = p[3]; }
}
// Skala auf die Eigen-Höhe beziehen: Canvas-Signaturen (vh=160) unverändert,
// hochgeladene SVGs werden auf dieselbe Zielhöhe eingepasst.
const s = scale * (SIG_CANVAS_H / vh);
// Alle Zeichen-Elemente extrahieren und IMMER als Single-Stroke ausgeben:
// fill:none + schwarzer Stroke inline; Klasse/Style/fill/stroke/width/height
// entfernen. So gehen per <style>-Klasse (z.B. .cls-1) gesetzte Linien nicht
// verloren (sonst fällt der Pfad auf fill:black zurück und rendert gefüllt).
const elemRe = /<(path|polyline|line|circle|ellipse|polygon)\b([^>]*?)\/?>/gi;
const elements = [];
let m;
while ((m = elemRe.exec(svgString)) !== null) {
const attrs = m[2].replace(/\s(?:class|style|fill|stroke|stroke-width|width|height)\s*=\s*"[^"]*"/gi, '');
elements.push(`<${m[1]}${attrs} fill="none" stroke="#000000"/>`);
}
if (!elements.length) return '';
// stroke-width auf der Gruppe (in Quell-Einheiten), damit nach scale ~2.5px herauskommt.
const sw = (2.5 / s).toFixed(2);
const tx = (x - minx * s).toFixed(1);
const ty = (y - miny * s).toFixed(1);
return `<g transform="translate(${tx}, ${ty}) scale(${s.toFixed(4)})" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">
${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'. Ist ein Absender gesetzt, sitzt der
// Empfänger UNTEN RECHTS (klassisches Kuvert), sonst wie bisher oben links.
const hatAbsender = !!String(options.sender || '').trim();
const maxWidthPx = Math.min(widthPx - marginLeftPx - 20, widthPx * (hatAbsender ? 0.36 : 0.6));
lines = wrapTextToLinesByWidth({
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx,
});
if (hatAbsender) {
// Empfänger unten rechts – bewusst über Höhen-/Breiten-Anteile, damit der
// große Kuvert-Rand (marginTop bis ~50 % der Höhe) nicht alles nach oben zieht.
startX = Math.round(widthPx * 0.60);
const untenBaseline = heightPx * 0.80; // unterste Zeile ~20 % über der Unterkante
startY = Math.round(untenBaseline - Math.max(0, lines.length - 1) * lineHeightPx);
} else {
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,
});
}
// Absender oben links – nur wenn gesetzt und kein zentrierter Freitext.
const absender = String(options.sender || '').trim();
if (absender && type !== 'custom') {
// Eigene Absender-Größe (Prozent, 1 = 100%). Grundgröße = Empfänger-Grundschrift
// (früher fix 0.62 → zu klein). Über options.senderScale prozentual einstellbar.
const senderScale = Math.max(0.5, Math.min(2, Number(options.senderScale) || 1));
const absSize = FONT_SIZE_PX * senderScale;
const absLH = lineHeightPx * (absSize / fontSizePx);
const absLines = absender.split('\n').map((l) => l.replace(/\s+$/, ''));
const absPaths = layoutTextToPaths({
lines: absLines,
fontFileName: fontConfig.file,
fontSizePx: absSize,
// Echt oben links – unabhängig vom großen Kuvert-Rand.
startX: Math.round(widthPx * 0.06),
startY: Math.round(heightPx * 0.14),
lineHeightPx: absLH,
realisticHandwriting: options.realisticHandwriting ?? false,
});
paths = [...paths, ...absPaths];
}
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
};