Rechnungsdesigner: Positionstabelle im Briefbogen-Layout, Hintergrundbild, mm-Angaben, Autosave

Positionstabelle mit Spalten Menge/Bezeichnung/Einzelpreis/Gesamt,
durchgezogenen schwarzen Spaltenlinien bis zum Summenblock (im Canvas aufs
Pixelraster eingerastet) und Summen rechts unten — identisch in Vorschau und
PDF. Dazu: Hintergrundbild hinter allen Elementen, Positionsangaben in mm,
Canvas-Eigenschaften in der Sidebar, kein Positionsraster mehr, zentrierter
Text bei Größenänderung sowie entprellter serverseitiger Autosave des
Arbeitsstands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 14:08:16 +02:00
co-authored by Claude Fable 5
parent 9891a58fad
commit 4d16455d17
6 changed files with 802 additions and 357 deletions
Binary file not shown.
@@ -73,7 +73,6 @@ window.initProfileInvoiceGenerator = function() {
var isDragging = false; var isDragging = false;
var dragStart = { x: 0, y: 0 }; var dragStart = { x: 0, y: 0 };
var elementStart = { x: 0, y: 0 }; var elementStart = { x: 0, y: 0 };
var gridSize = 5;
// Image cache to prevent flickering during resize // Image cache to prevent flickering during resize
var imageCache = {}; var imageCache = {};
@@ -102,8 +101,43 @@ window.initProfileInvoiceGenerator = function() {
pageY = (h - pageHeight) / 2; pageY = (h - pageHeight) / 2;
} }
// Kein Raster mehr: Positionen werden pixelgenau übernommen
function snapToGrid(value) { function snapToGrid(value) {
return Math.round(value / gridSize) * gridSize; return value;
}
// Freie Textelemente bleiben beim Verändern der Größe zentriert: Der Text
// wird mittig im Rahmen dargestellt (Canvas und PDF nutzen dieselbe
// textAlign-Eigenschaft, die Darstellung bleibt also identisch).
function keepTextCentered(el) {
if (el.type !== 'line' && el.type !== 'image' && !el.isStatic) {
el.textAlign = 'center';
}
}
// Mindestgröße eines Textelements (Basis-px): der dargestellte Text darf
// beim Verkleinern nicht abgeschnitten werden. Für andere Elementtypen
// gibt es keine inhaltsabhängige Untergrenze (null).
function minTextSize(el) {
if (el.type === 'line' || el.type === 'image' || el.variable === 'services.list') {
return null;
}
var fontSize = el.fontSize || 14;
ctx.save();
ctx.font = ((el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial')).trim();
var lines = (el.text || '').split('\n');
var maxLineWidth = 0;
lines.forEach(function(line) {
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
});
ctx.restore();
// Gleiche Polsterung wie der Auswahlrahmen (Breite +10, Höhe +6): so kann
// das Element nie kleiner werden als der gezeichnete Rahmen und der Text
// bleibt darin zentriert.
return {
width: Math.max(20, Math.ceil(maxLineWidth + 10)),
height: Math.max(20, Math.ceil(lines.length * fontSize * 1.2 + 6))
};
} }
// Notify Java about element selection // Notify Java about element selection
@@ -120,7 +154,8 @@ window.initProfileInvoiceGenerator = function() {
el.width || 100, el.width || 100,
el.height || 30, el.height || 30,
el.isStatic || false, el.isStatic || false,
el.variable || null el.variable || null,
el.fontFamily || 'Arial'
); );
} }
} }
@@ -131,6 +166,39 @@ window.initProfileInvoiceGenerator = function() {
} }
} }
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen in der Sidebar anzeigen
function notifyCanvasSelected() {
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
window.invoiceGeneratorViewProfile.$server.showCanvasProperties(
!!window.profileInvoiceState.backgroundImage);
}
}
// Entprellter Autosave: sichert den Canvas-Stand serverseitig, damit nach
// einem Seiten-Reload (z. B. durch Session-Ablauf) nichts verloren geht.
// Erst aktiv, nachdem der initiale Stand geladen wurde, damit ein leeres
// Canvas keinen vorhandenen Autosave überschreibt.
var autosaveTimer = null;
var lastAutosaved = null;
var autosaveEnabled = false;
function scheduleAutosave() {
if (!autosaveEnabled) return;
if (autosaveTimer) clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(function() {
try {
var snapshot = JSON.stringify(window.getProfileCanvasData());
if (snapshot === lastAutosaved) return;
lastAutosaved = snapshot;
if (window.invoiceGeneratorViewProfile && window.invoiceGeneratorViewProfile.$server) {
window.invoiceGeneratorViewProfile.$server.autosaveCanvas(snapshot);
}
} catch (err) {
console.error('Autosave fehlgeschlagen:', err);
}
}, 2000);
}
// Draw function // Draw function
function draw() { function draw() {
console.log('draw() called, elements:', elements.length); console.log('draw() called, elements:', elements.length);
@@ -156,21 +224,19 @@ window.initProfileInvoiceGenerator = function() {
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(pageX, pageY, pageWidth, pageHeight); ctx.strokeRect(pageX, pageY, pageWidth, pageHeight);
// Grid (scaled by zoom factor) // Hintergrundbild: auf Seitengröße skaliert, hinter allen Elementen
ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)'; var backgroundImage = window.profileInvoiceState.backgroundImage;
ctx.lineWidth = 0.5; if (backgroundImage) {
var scaledGridSize = gridSize * zoomFactor; if (imageCache[backgroundImage]) {
for (var x = pageX; x <= pageX + pageWidth; x += scaledGridSize) { ctx.drawImage(imageCache[backgroundImage], pageX, pageY, pageWidth, pageHeight);
ctx.beginPath(); } else {
ctx.moveTo(x, pageY); var bgImg = new Image();
ctx.lineTo(x, pageY + pageHeight); bgImg.onload = function() {
ctx.stroke(); imageCache[backgroundImage] = bgImg;
draw();
};
bgImg.src = backgroundImage;
} }
for (var y = pageY; y <= pageY + pageHeight; y += scaledGridSize) {
ctx.beginPath();
ctx.moveTo(pageX, y);
ctx.lineTo(pageX + pageWidth, y);
ctx.stroke();
} }
// Draw elements // Draw elements
@@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) { if (selectedElement) {
drawSelection(selectedElement); drawSelection(selectedElement);
} }
scheduleAutosave();
} }
function drawElement(el) { function drawElement(el) {
@@ -282,23 +350,36 @@ window.initProfileInvoiceGenerator = function() {
// Vertically center the text in the element // Vertically center the text in the element
var ty = y + (h - totalTextHeight) / 2; var ty = y + (h - totalTextHeight) / 2;
// Draw background highlight for static elements // Masterdata elements are never bold; other elements respect their fontStyle
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
// Set the font BEFORE measuring text, so the highlight width matches
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px ' + (el.fontFamily || 'Arial');
// Draw background highlight for static elements, fitted to the widest
// text line (not the element width) and following the text alignment
if (el.isStatic) { if (el.isStatic) {
var textWidth = ctx.measureText(el.text || '').width + (10 * zoomFactor); var maxLineWidth = 0;
lines.forEach(function(line) {
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
});
var bgWidth = maxLineWidth + (10 * zoomFactor);
var bgX = x - (3 * zoomFactor);
if (textAlign === 'center') {
bgX = x + (w - bgWidth) / 2;
} else if (textAlign === 'right') {
bgX = x + w - bgWidth + (3 * zoomFactor);
}
// Different background colors: green for customer, blue for masterdata // Different background colors: green for customer, blue for masterdata
if (el.isCustomer) { if (el.isCustomer) {
ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer ctx.fillStyle = 'rgba(46, 204, 113, 0.15)'; // Light green for customer
} else { } else {
ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata ctx.fillStyle = 'rgba(25, 118, 210, 0.1)'; // Light blue for masterdata
} }
ctx.fillRect(x - (3 * zoomFactor), y - (2 * zoomFactor), Math.max(w, textWidth), h); ctx.fillRect(bgX, y - (2 * zoomFactor), bgWidth, h);
} }
// Always use black text for static elements, otherwise use element color // Die gewählte Elementfarbe verwenden — wie im gerenderten PDF
ctx.fillStyle = el.isStatic ? '#000000' : (el.color || '#333333'); ctx.fillStyle = el.color || '#333333';
// Masterdata elements are never bold; other elements respect their fontStyle
var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
ctx.font = (fontWeight ? fontWeight + ' ' : '') + fontSize + 'px Arial';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.textAlign = textAlign; ctx.textAlign = textAlign;
@@ -318,140 +399,94 @@ window.initProfileInvoiceGenerator = function() {
ctx.restore(); ctx.restore();
} }
// Draw services list as a table with columns: Name, Steuersatz, Nettobetrag // Positionsliste im Briefbogen-Layout: Spalten Menge, Bezeichnung,
// Plus summary section below: Nettosumme, USt, Gesamtsumme // Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum Summenblock
// und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
function drawServicesTable(el, x, y, w, h, fontSize) { function drawServicesTable(el, x, y, w, h, fontSize) {
var fontFamily = el.fontFamily || 'Arial';
var lineHeight = fontSize * 1.4; var lineHeight = fontSize * 1.4;
var padding = 4 * zoomFactor; var padding = 4 * zoomFactor;
var rowHeight = lineHeight + padding * 2; var rowHeight = lineHeight + padding * 2;
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
// Column widths (percentages of total width) // Spaltengrenzen wie im PDF-Renderer (9 / 61 / 15 / 15 Prozent)
var colNameWidth = w * 0.55; // 55% for Name (left-aligned) var xName = x + w * 0.09;
var colVatWidth = w * 0.20; // 20% for Steuersatz (right-aligned) var xUnit = x + w * 0.70;
var colNetWidth = w * 0.25; // 25% for Nettobetrag (right-aligned) var xTotal = x + w * 0.85;
var colNameX = x;
var colVatX = x + colNameWidth;
var colNetX = colVatX + colVatWidth;
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19; var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%'; var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
// Rows come from window.profileInvoiceServiceData when provided (system
// invoice template: the three admin price table positions); otherwise
// fall back to sample data (per-user profile invoice generator)
var serviceData = window.profileInvoiceServiceData; var serviceData = window.profileInvoiceServiceData;
var rows = (serviceData && serviceData.rows && serviceData.rows.length) var rows = (serviceData && serviceData.rows && serviceData.rows.length)
? serviceData.rows ? serviceData.rows
: [ : [
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 ' }, { quantity: '1', name: 'Umzugsleistung inkl. Verpackung', unitPrice: '450,00 \u20ac', net: '450,00 \u20ac' },
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 ' }, { quantity: '1', name: 'Entsorgung M\u00f6bel', unitPrice: '85,00 \u20ac', net: '85,00 \u20ac' },
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 ' } { quantity: '1', name: 'Montage/De-Montage', unitPrice: '120,00 \u20ac', net: '120,00 \u20ac' }
]; ];
// Calculate actual content height based on table + summary // Mindesthoehe: Kopf + Zeilen + Summenblock; ist das Element hoeher,
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows // laufen die Spaltenlinien entsprechend weiter nach unten
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight; var minHeight = rowHeight * (rows.length + 1) + summaryHeight;
// Ensure background covers at least the element height or the calculated content var totalHeight = Math.max(h, minHeight);
var bgHeight = Math.max(h, calculatedContentHeight * zoomFactor); var bodyBottom = y + totalHeight - summaryHeight;
// Draw orange background highlight for entire service variable element // Orangefarbene Markierung des gesamten Bausteins (nur im Designer)
var bgPadding = 3 * zoomFactor; var bgPadding = 3 * zoomFactor;
ctx.fillStyle = 'rgba(255, 152, 0, 0.15)'; ctx.fillStyle = 'rgba(255, 152, 0, 0.15)';
ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), bgHeight + (4 * zoomFactor)); ctx.fillRect(x - bgPadding, y - (2 * zoomFactor), w + (2 * bgPadding), totalHeight + (4 * zoomFactor));
// Draw table header (overlays the background) // Kopfzeile: graue Flaeche mit kleinen Beschriftungen
ctx.fillStyle = '#f5f5f5'; ctx.fillStyle = '#eeeeee';
ctx.fillRect(x, y, w, rowHeight); ctx.fillRect(x, y, w, rowHeight);
// Header border
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = Math.max(0.5, zoomFactor);
ctx.beginPath();
ctx.moveTo(x, y + rowHeight);
ctx.lineTo(x + w, y + rowHeight);
ctx.stroke();
// Header text
ctx.fillStyle = '#333333'; ctx.fillStyle = '#333333';
ctx.font = 'bold ' + fontSize + 'px Arial'; ctx.font = (fontSize * 0.75) + 'px ' + fontFamily;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
// Name column header (left-aligned)
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2); ctx.fillText('Menge', x + padding, y + rowHeight / 2);
ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2);
// Steuersatz column header (right-aligned) ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2);
ctx.textAlign = 'right'; ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2);
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
// Datenzeilen
var currentY = y + rowHeight; var currentY = y + rowHeight;
ctx.font = fontSize + 'px ' + fontFamily;
// Draw data rows rows.forEach(function(row) {
ctx.font = fontSize + 'px Arial';
rows.forEach(function(row, index) {
// Draw row background (alternating)
if (index % 2 === 1) {
ctx.fillStyle = 'rgba(0,0,0,0.02)';
ctx.fillRect(x, currentY, w, rowHeight);
}
// Row bottom border
ctx.strokeStyle = '#eeeeee';
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5);
ctx.beginPath();
ctx.moveTo(x, currentY + rowHeight);
ctx.lineTo(x + w, currentY + rowHeight);
ctx.stroke();
// Draw cell text
ctx.fillStyle = '#333333'; ctx.fillStyle = '#333333';
ctx.textAlign = 'center';
// Name (left-aligned) ctx.fillText(row.quantity || '', x + (xName - x) / 2, currentY + rowHeight / 2);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(row.name, colNameX + padding, currentY + rowHeight / 2); ctx.fillText(row.name || '', xName + padding, currentY + rowHeight / 2);
// Steuersatz (right-aligned)
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(row.vat, colVatX + colVatWidth - padding, currentY + rowHeight / 2); ctx.fillText(row.unitPrice || '', xTotal - padding, currentY + rowHeight / 2);
ctx.fillText(row.net || '', x + w - padding, currentY + rowHeight / 2);
// Nettobetrag (right-aligned)
ctx.fillText(row.net, colNetX + colNetWidth - padding, currentY + rowHeight / 2);
currentY += rowHeight; currentY += rowHeight;
}); });
// Draw column separator lines // Spaltenlinien vom Kopf bis zum Summenblock (inkl. Abstand davor,
ctx.strokeStyle = '#e0e0e0'; // damit keine Luecke zur ersten Summenzeile entsteht). X-Koordinaten
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); // aufs Pixelraster einrasten (n + 0,5), sonst verwischt der Browser
// die 1px-Linien je nach Subpixel-Lage zu breiten grauen Baendern.
var linesBottom = bodyBottom + summaryGap;
var lineXName = Math.round(xName) + 0.5;
var lineXUnit = Math.round(xUnit) + 0.5;
var lineXTotal = Math.round(xTotal) + 0.5;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
// Line between Name and Steuersatz ctx.moveTo(lineXName, y);
ctx.moveTo(colVatX, y); ctx.lineTo(lineXName, linesBottom);
ctx.lineTo(colVatX, currentY); ctx.moveTo(lineXUnit, y);
// Line between Steuersatz and Nettobetrag ctx.lineTo(lineXUnit, linesBottom);
ctx.moveTo(colNetX, y); ctx.moveTo(lineXTotal, y);
ctx.lineTo(colNetX, currentY); ctx.lineTo(lineXTotal, linesBottom);
ctx.stroke(); ctx.stroke();
// Draw outer border around table // Summen: Nettobetrag, MwSt., Endbetrag rechts unten
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = Math.max(0.5, zoomFactor);
ctx.strokeRect(x, y, w, currentY - y);
// Draw summary section below the table
var summaryY = currentY + summaryGap;
// Column positions for summary section
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
var valueX = x + w - padding; // Value column (right-aligned)
// Totals: provided with the service data or calculated from sample data
var netTotalLabel, vatTotalLabel, grossTotalLabel; var netTotalLabel, vatTotalLabel, grossTotalLabel;
if (serviceData && serviceData.netTotal) { if (serviceData && serviceData.netTotal) {
netTotalLabel = serviceData.netTotal; netTotalLabel = serviceData.netTotal;
@@ -460,41 +495,41 @@ window.initProfileInvoiceGenerator = function() {
} else { } else {
var netTotal = 655.00; // 450 + 85 + 120 var netTotal = 655.00; // 450 + 85 + 120
var vatTotal = netTotal * vatRate; var vatTotal = netTotal * vatRate;
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' '; netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' \u20ac';
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' '; vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' \u20ac';
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' '; grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' \u20ac';
} }
// Draw summary lines var summaryRows = [
{ label: 'Nettobetrag', value: netTotalLabel, shaded: true },
{ label: '+ ' + vatPctLabel + ' MwSt.', value: vatTotalLabel, shaded: false },
{ label: 'Endbetrag', value: grossTotalLabel, shaded: true }
];
var summaryY = bodyBottom + summaryGap;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
summaryRows.forEach(function(row) {
if (row.shaded) {
ctx.fillStyle = '#eeeeee';
ctx.fillRect(xUnit, summaryY, x + w - xUnit, summaryRowHeight);
}
// Fortsetzung der Spaltenlinie links neben den Summen-Labels
// (gleiche eingerastete X-Koordinate wie die Spaltenlinie darueber)
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.round(xUnit) + 0.5, summaryY);
ctx.lineTo(Math.round(xUnit) + 0.5, summaryY + summaryRowHeight);
ctx.stroke();
// Nettosumme - label left, value right
ctx.fillStyle = '#333333'; ctx.fillStyle = '#333333';
ctx.font = fontSize + 'px Arial'; ctx.font = fontSize + 'px ' + fontFamily;
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); ctx.fillText(row.label, xUnit + padding, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2); ctx.fillText(row.value, x + w - padding, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight; summaryY += summaryRowHeight;
});
// Umsatzsteuer - label left, value right
ctx.font = fontSize + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
ctx.font = 'bold ' + fontSize + 'px Arial';
ctx.textAlign = 'right';
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
summaryY += summaryRowHeight;
// Gesamtsumme - label left, value right
summaryY += summaryGap; // Extra gap before total
ctx.fillStyle = '#000000';
ctx.font = 'bold ' + (fontSize * 1.1) + 'px Arial';
ctx.textAlign = 'left';
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
ctx.textAlign = 'right';
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
} }
function drawSelection(el) { function drawSelection(el) {
@@ -506,7 +541,7 @@ window.initProfileInvoiceGenerator = function() {
// For text elements, calculate actual text dimensions // For text elements, calculate actual text dimensions
if (el.type !== 'line' && el.type !== 'image') { if (el.type !== 'line' && el.type !== 'image') {
var fontSize = (el.fontSize || 14) * zoomFactor; var fontSize = (el.fontSize || 14) * zoomFactor;
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
// For services.list, calculate table height including summary section // For services.list, calculate table height including summary section
if (el.variable === 'services.list') { if (el.variable === 'services.list') {
@@ -516,7 +551,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var totalHeight = tableHeight + summaryHeight; var totalHeight = tableHeight + summaryHeight;
h = Math.max(h, totalHeight); h = Math.max(h, totalHeight);
} else { } else {
@@ -571,18 +606,27 @@ window.initProfileInvoiceGenerator = function() {
}); });
} }
// Der anklickbare Bereich entspricht dem dargestellten Inhalt, nicht dem
// (ggf. größeren) Elementrahmen.
function hitTest(x, y, el) { function hitTest(x, y, el) {
var ex = pageX + (el.x * zoomFactor); var ex = pageX + (el.x * zoomFactor);
var ey = pageY + (el.y * zoomFactor); var ey = pageY + (el.y * zoomFactor);
var ew = (el.width || 100) * zoomFactor; var ew = (el.width || 100) * zoomFactor;
var eh = (el.height || 30) * zoomFactor; var eh = (el.height || 30) * zoomFactor;
// For text elements, calculate actual text dimensions for hit testing if (el.type === 'line') {
if (el.type !== 'line' && el.type !== 'image') { // Nur die gezeichnete Linie, mit kleiner vertikaler Toleranz
var fontSize = (el.fontSize || 14) * zoomFactor; var tolerance = Math.max(3, 4 * zoomFactor);
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px Arial'; return x >= ex && x <= ex + ew && y >= ey - tolerance && y <= ey + tolerance;
}
if (el.type === 'image') {
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
}
// For services.list, calculate table height var fontSize = (el.fontSize || 14) * zoomFactor;
ctx.font = (el.fontStyle || '') + ' ' + fontSize + 'px ' + (el.fontFamily || 'Arial');
// services.list: die Tabelle füllt die Elementbreite, Höhe wie gezeichnet
if (el.variable === 'services.list') { if (el.variable === 'services.list') {
var lineHeight = fontSize * 1.4; var lineHeight = fontSize * 1.4;
var padding = 4 * zoomFactor; var padding = 4 * zoomFactor;
@@ -590,25 +634,32 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
eh = Math.max(eh, tableHeight + summaryHeight); eh = Math.max(eh, tableHeight + summaryHeight);
} else { return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
}
// Textelemente: Maße des gezeichneten Texts (breiteste Zeile, Zeilenhöhe),
// horizontal gemäß Ausrichtung, vertikal zentriert wie beim Zeichnen
var lines = (el.text || '').split('\n'); var lines = (el.text || '').split('\n');
var maxLineWidth = 0; var maxLineWidth = 0;
lines.forEach(function(line) { lines.forEach(function(line) {
var lineWidth = ctx.measureText(line).width; maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
maxLineWidth = Math.max(maxLineWidth, lineWidth);
}); });
var textHeight = lines.length * fontSize * 1.2;
ew = Math.max(ew, maxLineWidth + (10 * zoomFactor)); var textAlign = el.textAlign || 'left';
var contentX = ex;
var lineHeight = fontSize * 1.2; if (textAlign === 'center') {
var textHeight = lines.length * lineHeight; contentX = ex + (ew - maxLineWidth) / 2;
eh = Math.max(eh, textHeight + (6 * zoomFactor)); } else if (textAlign === 'right') {
} contentX = ex + ew - maxLineWidth;
} }
var contentY = ey + (eh - textHeight) / 2;
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; var pad = 3 * zoomFactor;
return x >= contentX - pad && x <= contentX + maxLineWidth + pad
&& y >= contentY - pad && y <= contentY + textHeight + pad;
} }
// Resizing state // Resizing state
@@ -636,7 +687,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6; var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5; var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
eh = Math.max(eh, tableHeight + summaryHeight); eh = Math.max(eh, tableHeight + summaryHeight);
} }
@@ -708,8 +759,9 @@ window.initProfileInvoiceGenerator = function() {
canvas.style.cursor = 'move'; canvas.style.cursor = 'move';
notifyElementSelected(selectedElement); notifyElementSelected(selectedElement);
} else { } else {
// Klick auf die leere Zeichenfläche: Canvas-Einstellungen anzeigen
selectedElement = null; selectedElement = null;
notifyElementDeselected(); notifyCanvasSelected();
} }
draw(); draw();
@@ -730,41 +782,47 @@ window.initProfileInvoiceGenerator = function() {
var newX = resizeStart.elemX; var newX = resizeStart.elemX;
var newY = resizeStart.elemY; var newY = resizeStart.elemY;
// Textelemente lassen sich nur bis zur Größe des dargestellten Texts
// verkleinern; alle anderen Typen behalten das bisherige Minimum.
var minSize = minTextSize(selectedElement);
var minW = minSize ? minSize.width : 20;
var minH = minSize ? minSize.height : 20;
// Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR // Handle indices: 0=TL, 1=TC, 2=TR, 3=ML, 4=MR, 5=BL, 6=BC, 7=BR
switch(resizeHandle) { switch(resizeHandle) {
case 0: // Top-left case 0: // Top-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 1: // Top-center case 1: // Top-center
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 2: // Top-right case 2: // Top-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
newHeight = Math.max(20, resizeStart.height - dy); newHeight = Math.max(minH, resizeStart.height - dy);
newY = resizeStart.elemY + (resizeStart.height - newHeight); newY = resizeStart.elemY + (resizeStart.height - newHeight);
break; break;
case 3: // Middle-left case 3: // Middle-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
break; break;
case 4: // Middle-right case 4: // Middle-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
break; break;
case 5: // Bottom-left case 5: // Bottom-left
newWidth = Math.max(20, resizeStart.width - dx); newWidth = Math.max(minW, resizeStart.width - dx);
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
newX = resizeStart.elemX + (resizeStart.width - newWidth); newX = resizeStart.elemX + (resizeStart.width - newWidth);
break; break;
case 6: // Bottom-center case 6: // Bottom-center
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
break; break;
case 7: // Bottom-right case 7: // Bottom-right
newWidth = Math.max(20, resizeStart.width + dx); newWidth = Math.max(minW, resizeStart.width + dx);
newHeight = Math.max(20, resizeStart.height + dy); newHeight = Math.max(minH, resizeStart.height + dy);
break; break;
} }
@@ -772,6 +830,7 @@ window.initProfileInvoiceGenerator = function() {
selectedElement.height = snapToGrid(newHeight); selectedElement.height = snapToGrid(newHeight);
selectedElement.x = snapToGrid(newX); selectedElement.x = snapToGrid(newX);
selectedElement.y = snapToGrid(newY); selectedElement.y = snapToGrid(newY);
keepTextCentered(selectedElement);
draw(); draw();
notifyElementSelected(selectedElement); notifyElementSelected(selectedElement);
@@ -856,40 +915,29 @@ window.initProfileInvoiceGenerator = function() {
var moved = false; var moved = false;
// Pfeiltasten bewegen um 0,1 mm, mit Shift um 0,5 mm (A4: 210 x 297 mm)
var stepMm = e.shiftKey ? 0.5 : 0.1;
var stepX = stepMm / 210 * basePageWidth;
var stepY = stepMm / 297 * basePageHeight;
switch(e.key) { switch(e.key) {
case 'ArrowUp': case 'ArrowUp':
if (e.shiftKey) { selectedElement.y = Math.max(0, selectedElement.y - stepY);
selectedElement.y = Math.max(0, selectedElement.y - 1);
} else {
selectedElement.y = Math.max(0, Math.floor((selectedElement.y - 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowDown': case 'ArrowDown':
if (e.shiftKey) { selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + stepY);
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), selectedElement.y + 1);
} else {
selectedElement.y = Math.min(basePageHeight - (selectedElement.height || 30), Math.ceil((selectedElement.y + 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowLeft': case 'ArrowLeft':
if (e.shiftKey) { selectedElement.x = Math.max(0, selectedElement.x - stepX);
selectedElement.x = Math.max(0, selectedElement.x - 1);
} else {
selectedElement.x = Math.max(0, Math.floor((selectedElement.x - 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
case 'ArrowRight': case 'ArrowRight':
if (e.shiftKey) { selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + stepX);
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), selectedElement.x + 1);
} else {
selectedElement.x = Math.min(basePageWidth - (selectedElement.width || 100), Math.ceil((selectedElement.x + 1) / gridSize) * gridSize);
}
moved = true; moved = true;
e.preventDefault(); e.preventDefault();
break; break;
@@ -933,6 +981,7 @@ window.initProfileInvoiceGenerator = function() {
width: 150, width: 150,
height: 30, height: 30,
fontSize: 14, fontSize: 14,
fontFamily: 'Arial',
color: '#333333', color: '#333333',
isStatic: isStatic || false, isStatic: isStatic || false,
variable: variable || null, variable: variable || null,
@@ -959,7 +1008,7 @@ window.initProfileInvoiceGenerator = function() {
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 sample rows
var summaryRowHeight = el.fontSize * 1.6; var summaryRowHeight = el.fontSize * 1.6;
var summaryGap = el.fontSize * 0.5; var summaryGap = el.fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap; var summaryHeight = 3 * summaryRowHeight + summaryGap;
var totalHeight = tableHeight + summaryHeight; var totalHeight = tableHeight + summaryHeight;
el.height = Math.round(totalHeight); el.height = Math.round(totalHeight);
} else { } else {
@@ -1019,6 +1068,7 @@ window.initProfileInvoiceGenerator = function() {
elements.push(el); elements.push(el);
selectedElement = el; selectedElement = el;
autosaveEnabled = true;
draw(); draw();
notifyElementSelected(el); notifyElementSelected(el);
@@ -1032,6 +1082,46 @@ window.initProfileInvoiceGenerator = function() {
if (el) { if (el) {
pushUndoState(); pushUndoState();
el.text = text; el.text = text;
// Höhe an die Zeilenzahl anpassen, damit mehrzeiliger Text ins Element
// passt; Breite mindestens auf die breiteste Zeile aufweiten
if (el.type !== 'line' && el.type !== 'image') {
var lines = (text || '').split('\n');
var lineHeight = (el.fontSize || 14) * 1.2;
el.height = Math.round(lines.length * lineHeight + 6);
var minSize = minTextSize(el);
if (minSize) {
el.width = Math.max(el.width || 0, minSize.width);
}
}
draw();
}
};
window.updateProfileElementSize = function(id, width, height) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
var minSize = minTextSize(el);
var minW = minSize ? minSize.width : 1;
var minH = minSize ? minSize.height : 1;
if (width !== null) el.width = Math.max(minW, width);
if (height !== null) el.height = Math.max(minH, height);
keepTextCentered(el);
draw();
}
};
window.updateProfileElementFontFamily = function(id, family) {
var el = elements.find(function(e) { return e.id === id; });
if (el) {
pushUndoState();
el.fontFamily = family;
// Breite mindestens auf die breiteste Zeile in der neuen Schrift aufweiten
var minSize = minTextSize(el);
if (minSize) {
el.width = Math.max(el.width || 0, minSize.width);
el.height = Math.max(el.height || 0, minSize.height);
}
draw(); draw();
} }
}; };
@@ -1051,12 +1141,17 @@ window.initProfileInvoiceGenerator = function() {
if (el) { if (el) {
pushUndoState(); pushUndoState();
el.fontSize = size; el.fontSize = size;
// Update height based on text content and new font size // Update height based on text content and new font size;
// Breite mindestens auf die breiteste Zeile aufweiten
if (el.type !== 'line' && el.type !== 'image') { if (el.type !== 'line' && el.type !== 'image') {
var lines = (el.text || '').split('\n'); var lines = (el.text || '').split('\n');
var lineHeight = size * 1.2; var lineHeight = size * 1.2;
var textHeight = lines.length * lineHeight; var textHeight = lines.length * lineHeight;
el.height = Math.max(textHeight + 6, 20); // Minimum height of 20 el.height = Math.max(textHeight + 6, 20); // Minimum height of 20
var minSize = minTextSize(el);
if (minSize) {
el.width = Math.max(el.width || 0, minSize.width);
}
} }
draw(); draw();
} }
@@ -1192,6 +1287,7 @@ window.initProfileInvoiceGenerator = function() {
heightPercent: toPercentY(el.height), heightPercent: toPercentY(el.height),
fontSize: el.fontSize, fontSize: el.fontSize,
fontStyle: el.fontStyle, fontStyle: el.fontStyle,
fontFamily: el.fontFamily,
textAlign: el.textAlign, textAlign: el.textAlign,
color: el.color, color: el.color,
isStatic: el.isStatic, isStatic: el.isStatic,
@@ -1200,11 +1296,19 @@ window.initProfileInvoiceGenerator = function() {
imageData: el.imageData imageData: el.imageData
}; };
}); });
// Das Hintergrundbild wird bewusst NICHT mitgeschickt: Es wird serverseitig
// gehalten und dort ins Template-JSON übernommen — die große Bild-Payload
// würde sonst das Limit für Client-zu-Server-Aufrufe sprengen.
return { return {
elements: elementsWithPercent elements: elementsWithPercent
}; };
}; };
window.updateProfileCanvasBackground = function(dataUrl) {
window.profileInvoiceState.backgroundImage = dataUrl || null;
draw();
};
window.updateProfileVatRate = function(rate) { window.updateProfileVatRate = function(rate) {
if (rate == null || isNaN(rate)) return; if (rate == null || isNaN(rate)) return;
window.profileInvoiceVatRate = rate; window.profileInvoiceVatRate = rate;
@@ -1275,6 +1379,7 @@ window.initProfileInvoiceGenerator = function() {
try { try {
console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200)); console.log('loadProfileTemplate called with data:', JSON.stringify(templateData).substring(0, 200));
var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData; var data = (typeof templateData === 'string') ? JSON.parse(templateData) : templateData;
window.profileInvoiceState.backgroundImage = data.backgroundImage || null;
if (data.elements && Array.isArray(data.elements)) { if (data.elements && Array.isArray(data.elements)) {
console.log('Loading ' + data.elements.length + ' elements'); console.log('Loading ' + data.elements.length + ' elements');
console.log('Current elements array before clear:', elements.length); console.log('Current elements array before clear:', elements.length);
@@ -1387,6 +1492,10 @@ window.initProfileInvoiceGenerator = function() {
// Save to global state // Save to global state
saveState(); saveState();
// Autosave erst ab jetzt: der geladene Stand ist die neue Referenz
lastAutosaved = JSON.stringify(window.getProfileCanvasData());
autosaveEnabled = true;
console.log('Calling draw(), elements count:', elements.length); console.log('Calling draw(), elements count:', elements.length);
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height); console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
draw(); draw();
@@ -31,6 +31,12 @@ public class InvoiceTemplateService {
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */ /** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
public static final String DEFAULT_TEMPLATE_NAME = "standard"; public static final String DEFAULT_TEMPLATE_NAME = "standard";
/**
* Reservierter Name für den automatisch gesicherten Arbeitsstand des
* Rechnungsgenerators; taucht in der Template-Auswahl nicht auf.
*/
public static final String AUTOSAVE_NAME = "__autosave__";
private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json"; private static final String LEGACY_TEMPLATE_FILE_NAME = "rechnungstemplate.json";
private final InvoiceTemplateRepository repository; private final InvoiceTemplateRepository repository;
@@ -64,10 +70,28 @@ public class InvoiceTemplateService {
migrateLegacyTemplateIfMissing(); migrateLegacyTemplateIfMissing();
return repository.findAll().stream() return repository.findAll().stream()
.map(template -> template.getName()) .map(template -> template.getName())
.filter(name -> !AUTOSAVE_NAME.equals(name))
.sorted(String.CASE_INSENSITIVE_ORDER) .sorted(String.CASE_INSENSITIVE_ORDER)
.toList(); .toList();
} }
/** Sichert den aktuellen Arbeitsstand des Rechnungsgenerators. */
public void saveAutosave(String templateData) {
saveTemplate(AUTOSAVE_NAME, templateData);
}
/** @return der zuletzt gesicherte Arbeitsstand oder leer, wenn keiner existiert. */
public Optional<String> loadAutosave() {
return repository.findByName(AUTOSAVE_NAME)
.map(template -> template.getTemplateData())
.filter(data -> !data.isBlank());
}
/** Verwirft den gesicherten Arbeitsstand, z. B. nach dem Speichern eines Templates. */
public void clearAutosave() {
repository.findByName(AUTOSAVE_NAME).ifPresent(repository::delete);
}
/** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */ /** @return das gespeicherte Template mit dem Namen oder leer, wenn keines existiert. */
public Optional<String> loadTemplate(String name) { public Optional<String> loadTemplate(String name) {
if (DEFAULT_TEMPLATE_NAME.equals(name)) { if (DEFAULT_TEMPLATE_NAME.equals(name)) {
@@ -3,13 +3,17 @@ package de.assecutor.pdftool.template;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter; import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -48,6 +52,20 @@ public class TemplatePdfService {
JsonNode rootNode = mapper.readTree(jsonTemplateData); JsonNode rootNode = mapper.readTree(jsonTemplateData);
JsonNode elements = rootNode.get("elements"); JsonNode elements = rootNode.get("elements");
// Hintergrundbild: als body-Hintergrund auf Seitengröße skaliert; alle
// Elemente liegen darüber. (Ein <img> wird von html2pdf an dieser
// Stelle nicht unterstützt, background-image dagegen schon.)
String backgroundCss = "";
JsonNode background = rootNode.get("backgroundImage");
if (background != null && !background.isNull() && !background.asText().isEmpty()) {
String imageData = background.asText();
if (!imageData.startsWith("data:")) {
imageData = "data:image/png;base64," + imageData;
}
backgroundCss = " background-image: url('" + imageData.replace("'", "%27") + "');"
+ " background-size: 210mm 297mm; background-repeat: no-repeat;";
}
StringBuilder htmlBuilder = new StringBuilder(); StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<!DOCTYPE html>"); htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>"); htmlBuilder.append("<html><head>");
@@ -55,7 +73,8 @@ public class TemplatePdfService {
htmlBuilder.append("<style>"); htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }"); htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append( htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }"); "body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif;"
+ backgroundCss + " }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }"); htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }"); htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }"); htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -72,8 +91,22 @@ public class TemplatePdfService {
htmlBuilder.append("</body></html>"); htmlBuilder.append("</body></html>");
// Standard-PDF-Fonts + mitgelieferte Noto-Fonts + Systemschriften, damit
// auch Schriften wie Verdana oder echtes Arial aufgelöst werden können.
// Der FontProvider darf nicht über Konvertierungen hinweg wiederverwendet
// werden, daher pro Aufruf eine neue Instanz.
ConverterProperties converterProperties = new ConverterProperties();
DefaultFontProvider fontProvider = new DefaultFontProvider(true, true, true);
// macOS legt viele Schriften (u. a. Verdana) unter "Supplemental" ab;
// dieses Verzeichnis gehört nicht zu den von iText gescannten Pfaden.
Path supplementalFonts = Path.of("/System/Library/Fonts/Supplemental");
if (Files.isDirectory(supplementalFonts)) {
fontProvider.addDirectory(supplementalFonts.toString());
}
converterProperties.setFontProvider(fontProvider);
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
HtmlConverter.convertToPdf(htmlBuilder.toString(), out); HtmlConverter.convertToPdf(htmlBuilder.toString(), out, converterProperties);
return out.toByteArray(); return out.toByteArray();
} }
@@ -103,6 +136,7 @@ public class TemplatePdfService {
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14; int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333"; String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left"; String textAlign = element.has("textAlign") ? element.get("textAlign").asText("left") : "left";
String fontFamily = element.has("fontFamily") ? element.get("fontFamily").asText("Arial") : "Arial";
// Prozent -> mm (A4: 210mm x 297mm) // Prozent -> mm (A4: 210mm x 297mm)
double mmX = xPercent / 100.0 * 210.0; double mmX = xPercent / 100.0 * 210.0;
@@ -126,6 +160,7 @@ public class TemplatePdfService {
htmlBuilder.append("font-size:").append(fontSize).append("pt;"); htmlBuilder.append("font-size:").append(fontSize).append("pt;");
htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); htmlBuilder.append("line-height:").append(String.format(Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";"); htmlBuilder.append("color:").append(color).append(";");
htmlBuilder.append("font-family:").append(cssFontStack(fontFamily)).append(";");
// services.list als Block, damit die Tabelle die Breite füllen kann // services.list als Block, damit die Tabelle die Breite füllen kann
if ("services.list".equals(variable)) { if ("services.list".equals(variable)) {
htmlBuilder.append("display:block;overflow:visible;padding:0;"); htmlBuilder.append("display:block;overflow:visible;padding:0;");
@@ -184,9 +219,9 @@ public class TemplatePdfService {
} }
} else if ("services.list".equals(variable)) { } else if ("services.list".equals(variable)) {
if (variables.containsKey("services.json")) { if (variables.containsKey("services.json")) {
htmlBuilder.append(generateServicesTableHtmlWithData(variables)); htmlBuilder.append(generateServicesTableHtmlWithData(variables, fontSize, mmHeight));
} else { } else {
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate)); htmlBuilder.append(generateServicesTableHtml(effectiveVatRate, fontSize, mmHeight));
} }
} else if (text.contains("<br>")) { } else if (text.contains("<br>")) {
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt // Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
@@ -199,7 +234,7 @@ public class TemplatePdfService {
} }
/** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */ /** Positionstabelle mit Beispieldaten, wenn keine echten Positionen übergeben wurden. */
private String generateServicesTableHtml(BigDecimal vatRate) { private String generateServicesTableHtml(BigDecimal vatRate, int fontSize, double mmHeight) {
BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP) BigDecimal pct = vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP)
.stripTrailingZeros(); .stripTrailingZeros();
if (pct.scale() < 0) { if (pct.scale() < 0) {
@@ -207,47 +242,23 @@ public class TemplatePdfService {
} }
String vatLabel = pct.toPlainString().replace('.', ',') + "%"; String vatLabel = pct.toPlainString().replace('.', ',') + "%";
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" }, List<Map<String, String>> rows = List.of(
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00" } }; Map.of("quantity", "1", "name", "Beratungsleistung", "unitPrice", "450,00", "netAmount", "450,00"),
Map.of("quantity", "1", "name", "Softwareentwicklung", "unitPrice", "1.200,00", "netAmount",
"1.200,00"),
Map.of("quantity", "1", "name", "Support-Pauschale", "unitPrice", "150,00", "netAmount", "150,00"));
double netTotal = 1800.00; double netTotal = 1800.00;
double grossTotal = netTotal + (netTotal * vatRate.doubleValue()); double vatTotal = netTotal * vatRate.doubleValue();
return renderServicesTable(rows,
StringBuilder html = new StringBuilder(); String.format(Locale.GERMANY, "%,.2f €", netTotal),
html.append("<div style='width:100%;box-sizing:border-box;'>"); String.format(Locale.GERMANY, "%,.2f €", vatTotal),
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); String.format(Locale.GERMANY, "%,.2f €", netTotal + vatTotal),
html.append(tableHeaderRow()); vatLabel, fontSize, mmHeight);
for (int i = 0; i < sampleData.length; i++) {
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : "";
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>");
html.append(
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'>")
.append(sampleData[i][0]).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][1])
.append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;'>").append(sampleData[i][2])
.append("</td>");
html.append("</tr>");
}
html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(summaryRow("Nettosumme:", String.format(Locale.GERMANY, "%,.2f €", netTotal), false));
html.append(summaryRow("Gesamtsumme:", String.format(Locale.GERMANY, "%,.2f €", grossTotal), true));
html.append("</table>");
html.append("</div>");
html.append("</div>");
return html.toString();
} }
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */ /** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
private String generateServicesTableHtmlWithData(Map<String, String> variables) { private String generateServicesTableHtmlWithData(Map<String, String> variables, int fontSize, double mmHeight) {
String netTotal = variables.getOrDefault("invoice.net_total", "0,00 €");
String vatTotal = variables.getOrDefault("invoice.vat_total", "0,00 €");
String grossTotal = variables.getOrDefault("invoice.gross_total", "0,00 €");
String vatRateLabel = variables.getOrDefault("invoice.vat_rate", "19%");
List<Map<String, String>> servicesData = new ArrayList<>(); List<Map<String, String>> servicesData = new ArrayList<>();
String servicesJson = variables.get("services.json"); String servicesJson = variables.get("services.json");
if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) { if (servicesJson != null && !servicesJson.isEmpty() && !servicesJson.equals("[]")) {
@@ -258,72 +269,120 @@ public class TemplatePdfService {
log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage()); log.warn("Positionsdaten (services.json) konnten nicht gelesen werden: {}", e.getMessage());
} }
} }
return renderServicesTable(servicesData,
variables.getOrDefault("invoice.net_total", "0,00 €"),
variables.getOrDefault("invoice.vat_total", "0,00 €"),
variables.getOrDefault("invoice.gross_total", "0,00 €"),
variables.getOrDefault("invoice.vat_rate", "19%"),
fontSize, mmHeight);
}
/**
* Rendert die Positionstabelle im Briefbogen-Layout: Spalten Menge,
* Bezeichnung, Einzelpreis und Gesamt, durchgezogene Spaltenlinien bis zum
* Summenblock und die Summen (Nettobetrag, MwSt., Endbetrag) rechts unten.
*/
private String renderServicesTable(List<Map<String, String>> rows, String netTotal, String vatTotal,
String grossTotal, String vatRateLabel, int fontSize, double mmHeight) {
String border = "0.5px solid #000000";
// Höhe der Füllzeile: Spaltenlinien bis zum Summenblock durchziehen,
// damit die Tabelle die Bausteinhöhe füllt (pt -> mm: Faktor 0,3528)
double lineMm = fontSize * 1.2 * 0.3528;
double headerMm = fontSize * 0.9 * 0.3528 + 1.6;
double rowMm = lineMm + 1.6;
double summaryMm = 3 * (lineMm + 1.2) + 2.0;
double fillerMm = Math.max(0, mmHeight - headerMm - rows.size() * rowMm - summaryMm);
StringBuilder html = new StringBuilder(); StringBuilder html = new StringBuilder();
html.append("<div style='width:100%;box-sizing:border-box;'>"); html.append("<div style='width:100%;box-sizing:border-box;'>");
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
html.append(tableHeaderRow()); html.append("<colgroup><col style='width:9%;'/><col style='width:61%;'/>")
.append("<col style='width:15%;'/><col style='width:15%;'/></colgroup>");
if (servicesData.isEmpty()) { String headStyle = "background-color:#eeeeee;font-size:0.75em;font-weight:normal;color:#333333;"
html.append("<tr style='border-bottom:1px solid #eeeeee;'>"); + "text-align:left;padding:2px 6px;white-space:nowrap;";
html.append( html.append("<tr>");
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>"); html.append("<th style='").append(headStyle).append("'>Menge</th>");
html.append("<th style='").append(headStyle).append("border-left:").append(border)
.append(";'>Bezeichnung</th>");
html.append("<th style='").append(headStyle).append("border-left:").append(border)
.append(";'>Einzelpreis</th>");
html.append("<th style='").append(headStyle).append("border-left:").append(border).append(";'>Gesamt</th>");
html.append("</tr>"); html.append("</tr>");
} else {
for (int i = 0; i < servicesData.size(); i++) {
Map<String, String> service = servicesData.get(i);
String name = service.getOrDefault("name", "Unbekannte Position");
String netAmount = service.getOrDefault("netAmount", "0,00");
// USt-Satz pro Position, Fallback: einheitlicher Satz der Rechnung
String rowVat = service.getOrDefault("vat", vatRateLabel);
String bgColor = (i % 2 == 1) ? "background-color:rgba(0,0,0,0.02);" : ""; if (rows.isEmpty()) {
html.append("<tr style='").append(bgColor).append("border-bottom:1px solid #eeeeee;'>"); html.append("<tr><td style='text-align:center;padding:2px 6px;'></td>")
html.append( .append("<td style='padding:2px 6px;border-left:").append(border)
"<td style='text-align:left;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:55%;'>") .append(";'>Keine Positionen vorhanden</td>")
.append(escapeHtml(name)).append("</td>"); .append("<td style='border-left:").append(border).append(";'></td>")
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>") .append("<td style='border-left:").append(border).append(";'></td></tr>");
.append(escapeHtml(rowVat)).append("</td>"); }
// € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert for (Map<String, String> row : rows) {
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + "" : escapeHtml(netAmount); html.append("<tr>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>") html.append("<td style='text-align:center;padding:2px 6px;white-space:nowrap;'>")
.append(amountDisplay).append("</td>"); .append(escapeHtml(row.getOrDefault("quantity", ""))).append("</td>");
html.append("<td style='text-align:left;padding:2px 6px;white-space:nowrap;overflow:hidden;")
.append("text-overflow:ellipsis;border-left:").append(border).append(";'>")
.append(escapeHtml(row.getOrDefault("name", ""))).append("</td>");
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
.append(";'>").append(amountDisplay(row.getOrDefault("unitPrice", ""))).append("</td>");
html.append("<td style='text-align:right;padding:2px 6px;white-space:nowrap;border-left:").append(border)
.append(";'>").append(amountDisplay(row.getOrDefault("netAmount", ""))).append("</td>");
html.append("</tr>"); html.append("</tr>");
} }
}
// Füllzeile: hält die Spaltenlinien bis zum Summenblock durch
html.append("<tr>");
html.append("<td style='height:").append(String.format(Locale.US, "%.2f", fillerMm)).append("mm;'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("<td style='border-left:").append(border).append(";'></td>");
html.append("</tr>");
html.append("</table>"); html.append("</table>");
html.append("<div style='margin-top:8px;width:100%;'>"); // Summenblock rechts unten: Nettobetrag, MwSt., Endbetrag
String vatLabelText = vatRateLabel == null || vatRateLabel.isEmpty()
? "+ MwSt."
: "+ " + escapeHtml(vatRateLabel) + " MwSt.";
html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>"); html.append("<table style='width:100%;border-collapse:collapse;font-size:inherit;table-layout:fixed;'>");
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften html.append("<colgroup><col style='width:70%;'/><col style='width:15%;'/><col style='width:15%;'/>")
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:" .append("</colgroup>");
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:"; html.append(summaryRow("Nettobetrag", escapeHtml(netTotal), true, border));
html.append(summaryRow("Nettosumme:", netTotal, false)); html.append(summaryRow(vatLabelText, escapeHtml(vatTotal), false, border));
html.append(summaryRow(vatSummaryLabel, vatTotal, false)); html.append(summaryRow("Endbetrag", escapeHtml(grossTotal), true, border));
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
html.append("</table>"); html.append("</table>");
html.append("</div>"); html.append("</div>");
html.append("</div>");
return html.toString(); return html.toString();
} }
private String tableHeaderRow() { /** Eine Zeile des Summenblocks; jede zweite Zeile ist grau hinterlegt. */
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>" private String summaryRow(String label, String value, boolean shaded, String border) {
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>" String bg = shaded ? "background-color:#eeeeee;" : "";
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>" return "<tr>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>" + "<td></td>"
+ "<td style='" + bg + "padding:2px 6px;white-space:nowrap;border-left:" + border + ";'>"
+ label + "</td>"
+ "<td style='" + bg + "padding:2px 6px;text-align:right;white-space:nowrap;'>" + value + "</td>"
+ "</tr>"; + "</tr>";
} }
private String summaryRow(String label, String value, boolean emphasized) { /** € nur an rein numerische Beträge anhängen; andere Werte escaped übernehmen. */
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;"; private String amountDisplay(String amount) {
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" return amount.matches("[0-9.,]+") ? amount + "" : escapeHtml(amount);
: "padding:2px 8px;font-weight:bold;"; }
return "<tr>"
+ "<td style='width:55%;padding:2px 0;'></td>" /**
+ "<td style='width:20%;text-align:left;white-space:nowrap;" + labelStyle + "'>" + label + "</td>" * CSS-Schriftstapel zur Schriftart des Elements; nur bekannte Werte werden
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>" * übernommen (Whitelist), alles andere fällt auf Arial zurück.
+ "</tr>"; */
private static String cssFontStack(String fontFamily) {
return switch (fontFamily) {
case "Times New Roman" -> "'Times New Roman', Times, serif";
case "Courier New" -> "'Courier New', Courier, monospace";
case "Verdana" -> "Verdana, 'DejaVu Sans', Arial, sans-serif";
default -> "Arial, Helvetica, sans-serif";
};
} }
private String escapeHtml(String input) { private String escapeHtml(String input) {
@@ -38,6 +38,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item); BigDecimal net = lineNet(item);
Map<String, String> position = new HashMap<>(); Map<String, String> position = new HashMap<>();
position.put("name", item.description()); position.put("name", item.description());
position.put("quantity", quantityLabel(item.quantity()));
position.put("unitPrice", formatAmount(item.unitPriceNet()));
position.put("netAmount", formatAmount(net)); position.put("netAmount", formatAmount(net));
position.put("vat", vatLabel(item.vatPercent())); position.put("vat", vatLabel(item.vatPercent()));
positions.add(position); positions.add(position);
@@ -71,6 +73,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item); BigDecimal net = lineNet(item);
Map<String, String> row = new HashMap<>(); Map<String, String> row = new HashMap<>();
row.put("name", item.description()); row.put("name", item.description());
row.put("quantity", quantityLabel(item.quantity()));
row.put("unitPrice", formatAmount(item.unitPriceNet()) + "");
row.put("vat", vatLabel(item.vatPercent())); row.put("vat", vatLabel(item.vatPercent()));
row.put("net", formatAmount(net) + ""); row.put("net", formatAmount(net) + "");
rows.add(row); rows.add(row);
@@ -97,6 +101,15 @@ public final class TemplateVariables {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ","); return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
} }
/** Menge als Anzeige-Label ohne überflüssige Nullen, z. B. "1" oder "2,5". */
public static String quantityLabel(BigDecimal quantity) {
BigDecimal normalized = quantity.stripTrailingZeros();
if (normalized.scale() < 0) {
normalized = normalized.setScale(0);
}
return normalized.toPlainString().replace('.', ',');
}
/** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */ /** USt-Satz als Anzeige-Label, z. B. "19%" oder "7,5%". */
public static String vatLabel(BigDecimal percent) { public static String vatLabel(BigDecimal percent) {
BigDecimal normalized = percent.stripTrailingZeros(); BigDecimal normalized = percent.stripTrailingZeros();
@@ -19,6 +19,7 @@ import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload; import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer; import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
@@ -38,6 +39,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -58,6 +60,13 @@ public class TemplateGeneratorView extends VerticalLayout {
private static final BigDecimal VAT_RATE = new BigDecimal("0.19"); private static final BigDecimal VAT_RATE = new BigDecimal("0.19");
// Canvas-Basisgröße (px) und A4-Maße (mm) zur Umrechnung der
// Positionsangaben in der Eigenschaften-Sidebar
private static final double BASE_PAGE_WIDTH_PX = 595;
private static final double BASE_PAGE_HEIGHT_PX = 842;
private static final double A4_WIDTH_MM = 210;
private static final double A4_HEIGHT_MM = 297;
private final TemplatePdfService templatePdfService; private final TemplatePdfService templatePdfService;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final CapturedInvoiceData capturedInvoiceData; private final CapturedInvoiceData capturedInvoiceData;
@@ -70,6 +79,15 @@ public class TemplateGeneratorView extends VerticalLayout {
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */ /** Name des zuletzt geladenen bzw. gespeicherten Templates. */
private String currentTemplateName; private String currentTemplateName;
/**
* Hintergrundbild der Zeichenfläche als Data-URL. Es wird serverseitig
* gehalten und erst beim Speichern bzw. für die Vorschau ins Template-JSON
* übernommen — die große Bild-Payload würde sonst bei jedem
* getProfileCanvasData-Aufruf das Limit für Client-zu-Server-Übertragungen
* sprengen.
*/
private String backgroundImage;
public TemplateGeneratorView(TemplatePdfService templatePdfService, public TemplateGeneratorView(TemplatePdfService templatePdfService,
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) { InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
this.templatePdfService = templatePdfService; this.templatePdfService = templatePdfService;
@@ -141,18 +159,67 @@ public class TemplateGeneratorView extends VerticalLayout {
loadInitialTemplate(); loadInitialTemplate();
} }
/** Lädt beim Start das Standard-Template bzw. das erste vorhandene Template. */ /**
* Beim Öffnen der Seite wird kein Template geladen; die Zeichenfläche
* bleibt leer, bis der Benutzer ein Template in der Combobox auswählt.
* Nur ein automatisch gesicherter Arbeitsstand (z. B. nach einem
* Seiten-Reload durch Session-Ablauf) wird wiederhergestellt.
*/
private void loadInitialTemplate() { private void loadInitialTemplate() {
List<String> names = invoiceTemplateService.templateNames(); List<String> names = invoiceTemplateService.templateNames();
templateSelect.setItems(names); templateSelect.setItems(names);
if (names.isEmpty()) {
return; Optional<String> autosave = invoiceTemplateService.loadAutosave();
if (autosave.isPresent()) {
restoreAutosave(autosave.get(), names);
}
}
/** Stellt den automatisch gesicherten Arbeitsstand auf der Zeichenfläche wieder her. */
private void restoreAutosave(String autosaveData, List<String> names) {
String templateName = readJsonTextField(autosaveData, "autosaveOf");
if (templateName != null && names.contains(templateName)) {
currentTemplateName = templateName;
templateSelect.setValue(templateName);
}
backgroundImage = readBackgroundImage(autosaveData);
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); "
+ " } else { console.error('loadProfileTemplate or canvas not available'); } "
+ "}, 300);", autosaveData);
showNotification("Nicht gespeicherte Änderungen wurden wiederhergestellt.");
}
/**
* Sichert den Canvas-Stand als Autosave (Aufruf aus dem JavaScript,
* zeitversetzt nach jeder Änderung). Das Hintergrundbild und der Name des
* zugrunde liegenden Templates werden serverseitig ergänzt.
*/
@ClientCallable
public void autosaveCanvas(String templateData) {
try {
ObjectMapper mapper = new ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(withBackgroundImage(templateData));
if (currentTemplateName != null && !currentTemplateName.isBlank()) {
root.put("autosaveOf", currentTemplateName);
}
invoiceTemplateService.saveAutosave(mapper.writeValueAsString(root));
} catch (Exception ex) {
log.warn("Autosave des Canvas fehlgeschlagen: {}", ex.getMessage());
}
}
/** Liest ein Textfeld aus dem Template-JSON; {@code null}, wenn nicht vorhanden. */
private static String readJsonTextField(String templateData, String fieldName) {
try {
com.fasterxml.jackson.databind.JsonNode node =
new ObjectMapper().readTree(templateData).get(fieldName);
return node == null || node.isNull() || node.asText().isEmpty() ? null : node.asText();
} catch (Exception ex) {
return null;
} }
String initial = names.contains(InvoiceTemplateService.DEFAULT_TEMPLATE_NAME)
? InvoiceTemplateService.DEFAULT_TEMPLATE_NAME
: names.get(0);
templateSelect.setValue(initial);
loadTemplateIntoCanvas(initial);
} }
/** Lädt das Template mit dem Namen auf die Zeichenfläche. */ /** Lädt das Template mit dem Namen auf die Zeichenfläche. */
@@ -164,6 +231,7 @@ public class TemplateGeneratorView extends VerticalLayout {
return; return;
} }
currentTemplateName = name; currentTemplateName = name;
backgroundImage = readBackgroundImage(templateData.get());
getElement().executeJs("setTimeout(function() { " getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " + " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); " + " window.loadProfileTemplate(JSON.parse($0)); "
@@ -234,6 +302,32 @@ public class TemplateGeneratorView extends VerticalLayout {
return TemplateVariables.canvasPositionsJson(effectiveItems()); return TemplateVariables.canvasPositionsJson(effectiveItems());
} }
/** Liest das Hintergrundbild aus dem Template-JSON; {@code null}, wenn keines gesetzt ist. */
private static String readBackgroundImage(String templateData) {
return readJsonTextField(templateData, "backgroundImage");
}
/**
* Ergänzt das serverseitig gehaltene Hintergrundbild im Template-JSON aus
* der Zeichenfläche (das JavaScript liefert es aus Größengründen nicht mit).
*/
private String withBackgroundImage(String templateData) {
try {
ObjectMapper mapper = new ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(templateData);
if (backgroundImage != null && !backgroundImage.isEmpty()) {
root.put("backgroundImage", backgroundImage);
} else {
root.remove("backgroundImage");
}
return mapper.writeValueAsString(root);
} catch (Exception ex) {
log.warn("Hintergrundbild konnte nicht ins Template übernommen werden: {}", ex.getMessage());
return templateData;
}
}
private String buildMasterdataJson() { private String buildMasterdataJson() {
try { try {
return new ObjectMapper().writeValueAsString(buildVariables()); return new ObjectMapper().writeValueAsString(buildVariables());
@@ -518,9 +612,13 @@ public class TemplateGeneratorView extends VerticalLayout {
return info; return info;
} }
/** Die im Designer und im PDF-Renderer verfügbaren Schriftarten. */
private static final List<String> FONT_FAMILIES = List.of("Arial", "Times New Roman", "Courier New", "Verdana");
@ClientCallable @ClientCallable
public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y, public void updatePropertiesPanel(String elementId, String elementType, String text, Double x, Double y,
Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable) { Integer fontSize, String color, Double width, Double height, Boolean isStatic, String variable,
String fontFamily) {
getUI().ifPresent(ui -> ui.access(() -> { getUI().ifPresent(ui -> ui.access(() -> {
propertiesPanel.removeAll(); propertiesPanel.removeAll();
@@ -573,11 +671,13 @@ public class TemplateGeneratorView extends VerticalLayout {
propertiesPanel.add(upload); propertiesPanel.add(upload);
} }
// Textfeld (nur für Text-Elemente) // Textfeld (nur für Text-Elemente); mehrzeilig, Umbrüche werden
// als Zeilenumbrüche in das Element übernommen
if (!"line".equals(elementType) && !"image".equals(elementType)) { if (!"line".equals(elementType) && !"image".equals(elementType)) {
TextField textField = new TextField("Text"); TextArea textField = new TextArea("Text");
textField.setValue(text != null ? text : ""); textField.setValue(text != null ? text : "");
textField.setWidthFull(); textField.setWidthFull();
textField.setMinHeight("6em");
if (Boolean.TRUE.equals(isStatic)) { if (Boolean.TRUE.equals(isStatic)) {
textField.setReadOnly(true); textField.setReadOnly(true);
textField.setHelperText("Wert wird aus den Stammdaten befüllt"); textField.setHelperText("Wert wird aus den Stammdaten befüllt");
@@ -589,40 +689,90 @@ public class TemplateGeneratorView extends VerticalLayout {
propertiesPanel.add(textField); propertiesPanel.add(textField);
} }
// X Position // X Position (Anzeige in mm, Canvas rechnet intern in px)
TextField xField = new TextField("X Position"); TextField xField = new TextField("X Position (mm)");
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0"); xField.setValue(formatMm(x != null ? pxToMm(x, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
xField.setWidthFull(); xField.setWidthFull();
xField.addValueChangeListener(e -> { xField.addValueChangeListener(e -> {
try { try {
double newX = Double.parseDouble(e.getValue()); double newXPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
getElement().executeJs( getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', $0, null); }", + elementId + "', $0, null); }",
newX); newXPx);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
propertiesPanel.add(xField); propertiesPanel.add(xField);
// Y Position // Y Position (Anzeige in mm, Canvas rechnet intern in px)
TextField yField = new TextField("Y Position"); TextField yField = new TextField("Y Position (mm)");
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0"); yField.setValue(formatMm(y != null ? pxToMm(y, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
yField.setWidthFull(); yField.setWidthFull();
yField.addValueChangeListener(e -> { yField.addValueChangeListener(e -> {
try { try {
double newY = Double.parseDouble(e.getValue()); double newYPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
getElement().executeJs( getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', null, $0); }", + elementId + "', null, $0); }",
newY); newYPx);
} catch (NumberFormatException ignored) { } catch (NumberFormatException ignored) {
} }
}); });
propertiesPanel.add(yField); propertiesPanel.add(yField);
// Schriftgröße und Farbe (nur für Text-Elemente) // Breite (Anzeige in mm); bei Linien bestimmt sie die Länge
TextField widthField = new TextField("Breite (mm)");
widthField.setValue(formatMm(width != null ? pxToMm(width, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
widthField.setWidthFull();
widthField.addValueChangeListener(e -> {
try {
double newWidthPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
getElement().executeJs(
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
+ elementId + "', $0, null); }",
newWidthPx);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(widthField);
// Höhe (Anzeige in mm); für Linien ohne Bedeutung
if (!"line".equals(elementType)) {
TextField heightField = new TextField("Höhe (mm)");
heightField.setValue(formatMm(height != null ? pxToMm(height, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
heightField.setWidthFull();
heightField.addValueChangeListener(e -> {
try {
double newHeightPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
getElement().executeJs(
"if (window.updateProfileElementSize) { window.updateProfileElementSize('"
+ elementId + "', null, $0); }",
newHeightPx);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(heightField);
}
// Schriftart, Schriftgröße und Farbe (nur für Text-Elemente)
if (!"line".equals(elementType) && !"image".equals(elementType)) { if (!"line".equals(elementType) && !"image".equals(elementType)) {
ComboBox<String> fontFamilySelect = new ComboBox<>("Schriftart");
fontFamilySelect.setItems(FONT_FAMILIES);
fontFamilySelect.setValue(fontFamily != null && FONT_FAMILIES.contains(fontFamily)
? fontFamily
: "Arial");
fontFamilySelect.setWidthFull();
fontFamilySelect.addValueChangeListener(e -> {
if (e.getValue() != null) {
getElement().executeJs(
"if (window.updateProfileElementFontFamily) { window.updateProfileElementFontFamily('"
+ elementId + "', $0); }",
e.getValue());
}
});
propertiesPanel.add(fontFamilySelect);
TextField fontSizeField = new TextField("Schriftgröße"); TextField fontSizeField = new TextField("Schriftgröße");
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16"); fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
fontSizeField.setWidthFull(); fontSizeField.setWidthFull();
@@ -692,6 +842,73 @@ public class TemplateGeneratorView extends VerticalLayout {
})); }));
} }
/**
* Zeigt die Einstellungen der Zeichenfläche in der Sidebar an (Aufruf aus
* dem JavaScript bei Klick auf die leere Zeichenfläche). Einzige
* Einstellung ist derzeit das Hintergrundbild: Es wird auf Seitengröße
* skaliert und liegt immer hinter allen Elementen.
*/
@ClientCallable
public void showCanvasProperties(Boolean hasBackground) {
getUI().ifPresent(ui -> ui.access(() -> {
propertiesPanel.removeAll();
Span typeLabel = new Span("Zeichenfläche");
typeLabel.addClassName("invoice-generator-info");
propertiesPanel.add(propertiesHeader(), typeLabel);
Span backgroundLabel = new Span("Hintergrundbild");
backgroundLabel.getStyle().set("font-weight", "bold")
.set("font-size", "var(--lumo-font-size-s)");
propertiesPanel.add(backgroundLabel);
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
upload.setAcceptedFileTypes("image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp");
upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB
upload.setDropLabel(new Span("Bild hierher ziehen oder klicken"));
upload.setWidthFull();
upload.addSucceededListener(event -> {
try {
byte[] bytes = buffer.getInputStream().readAllBytes();
String dataUrl = "data:" + event.getMIMEType() + ";base64,"
+ Base64.getEncoder().encodeToString(bytes);
backgroundImage = dataUrl;
getElement().executeJs(
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground($0); }",
dataUrl);
showNotification("Hintergrundbild übernommen.");
showCanvasProperties(true);
} catch (Exception ex) {
showNotification("Bild konnte nicht geladen werden: " + ex.getMessage());
}
});
upload.addFileRejectedListener(event ->
showNotification("Datei abgelehnt: " + event.getErrorMessage()));
propertiesPanel.add(upload);
Div info = new Div();
info.setText("Das Bild wird auf die Seitengröße skaliert und hinter allen Elementen angezeigt.");
info.addClassName("invoice-generator-info");
propertiesPanel.add(info);
if (Boolean.TRUE.equals(hasBackground)) {
Button removeBackground = new Button("Hintergrundbild entfernen", new Icon(VaadinIcon.TRASH));
removeBackground.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
removeBackground.setWidthFull();
removeBackground.addClickListener(e -> {
backgroundImage = null;
getElement().executeJs(
"if (window.updateProfileCanvasBackground) { window.updateProfileCanvasBackground(null); }");
showNotification("Hintergrundbild entfernt.");
showCanvasProperties(false);
});
propertiesPanel.add(removeBackground);
}
}));
}
@ClientCallable @ClientCallable
public void resetPropertiesPanel() { public void resetPropertiesPanel() {
getUI().ifPresent(ui -> ui.access(() -> { getUI().ifPresent(ui -> ui.access(() -> {
@@ -703,6 +920,26 @@ public class TemplateGeneratorView extends VerticalLayout {
})); }));
} }
/** Rechnet eine Canvas-Position (px) in mm auf der A4-Seite um. */
private static double pxToMm(double px, double basePx, double sizeMm) {
return px / basePx * sizeMm;
}
/** Rechnet eine mm-Angabe in die Canvas-Position (px) um. */
private static double mmToPx(double mm, double basePx, double sizeMm) {
return mm / sizeMm * basePx;
}
/** Formatiert eine mm-Angabe mit einer Nachkommastelle, z. B. "20,5". */
private static String formatMm(double mm) {
return String.format(Locale.GERMANY, "%.1f", mm);
}
/** Liest eine mm-Angabe; Komma und Punkt sind als Dezimaltrenner erlaubt. */
private static double parseMm(String value) {
return Double.parseDouble(value.trim().replace(',', '.'));
}
private String getVariableDescription(String variable) { private String getVariableDescription(String variable) {
return switch (variable) { return switch (variable) {
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers"; case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
@@ -778,7 +1015,7 @@ public class TemplateGeneratorView extends VerticalLayout {
showNotification("Zeichenfläche ist nicht bereit."); showNotification("Zeichenfläche ist nicht bereit.");
return; return;
} }
openSaveDialog(templateData); openSaveDialog(withBackgroundImage(templateData));
}); });
} }
@@ -831,6 +1068,8 @@ public class TemplateGeneratorView extends VerticalLayout {
private void doSaveTemplate(String name, String templateData) { private void doSaveTemplate(String name, String templateData) {
try { try {
invoiceTemplateService.saveTemplate(name, templateData); invoiceTemplateService.saveTemplate(name, templateData);
// Explizit gespeichert: der Autosave-Arbeitsstand ist damit erledigt.
invoiceTemplateService.clearAutosave();
currentTemplateName = name; currentTemplateName = name;
templateSelect.setItems(invoiceTemplateService.templateNames()); templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setValue(name); templateSelect.setValue(name);
@@ -849,7 +1088,8 @@ public class TemplateGeneratorView extends VerticalLayout {
return; return;
} }
try { try {
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE); byte[] pdfBytes = templatePdfService.generatePdf(withBackgroundImage(templateData),
buildVariables(), VAT_RATE);
showPdfInDialog(pdfBytes); showPdfInDialog(pdfBytes);
} catch (Exception ex) { } catch (Exception ex) {
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage()); showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());