Tabulator-Support in Brieftexten + ausstehende Batches

Tabs (Eingabe + Rendering):
- Tab-Taste fügt echten Tabulator ein (App-Konfigurator + Directus
  manueller Druck); \t in beiden Zeichen-Whitelists erlaubt.
- Renderer: Zeilen mit führenden Tabs werden nicht mehr nach Wortbreite
  umgebrochen; Tab-Spruenge an den rechten Rand geclampt; bei Ueberlauf
  rechtsbuendig (Datum ganz rechts, ohne Absturz nach links). Tab-loser
  Text bleibt bit-identisch.

Mailer:
- loadSettings bricht nicht mehr ab, wenn team_email fehlt (Team-
  Empfaenger separat gelesen).
- Eingebaute Fallback-Vorlagen fuer order_created + Status-Mails, damit
  Bestaetigung/Status auch ohne DB-Vorlage zugestellt werden.

Weitere ausstehende Arbeit:
- Abweichender Text je Schriftstueck (#5) in Generierung/Konfigurator.
- Produktions-Board: Format-Sammelgruppen (Bulk) + Bootstrap-Feld.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-08-04 16:32:11 +02:00
parent 0b2f2ba50a
commit db419bf8eb
9 changed files with 191 additions and 30 deletions

View File

@@ -63,10 +63,12 @@ function buildDocuments(order, entries) {
const nr = Number(entry.letter_number) || (i + 1);
if (!istDatei) {
// 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: order.text_template || '',
text: briefText,
format: letterFormat,
font,
placeholders,

View File

@@ -78,8 +78,10 @@ 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 }) {
function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const tabStopPx = fontSizePx * 6;
@@ -88,7 +90,7 @@ function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
for (const ch of String(text || '')) {
if (ch === TAB_MARKER) {
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx;
x = Math.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx);
continue;
}
@@ -126,6 +128,19 @@ function wrapTextToLinesByWidth({
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 = '';
@@ -208,6 +223,7 @@ function layoutTextToPaths({
startY,
lineHeightPx,
realisticHandwriting = false,
maxWidthPx = Infinity,
}) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
@@ -223,13 +239,32 @@ function layoutTextToPaths({
const tabStopPx = fontSizePx * 6;
for (let i = 0; i < line.length; i++) {
// 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
// Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt)
if (ch === TAB_MARKER) {
const relX = x - startX;
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx;
const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx);
x = startX + nextTab;
continue;
}

View File

@@ -92,6 +92,7 @@ function generateLetterSVG(text, format, options = {}) {
startY: firstBaselineY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
});
const pathElements = paths
@@ -167,6 +168,7 @@ function generateLetterSVG(text, format, options = {}) {
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
});
paths.forEach(p => allSvgElements.push(
`<path d="${escapeXml(p.d)}" transform="${p.transform}"