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
@@ -31,6 +31,12 @@ public class InvoiceTemplateService {
/** Name des Standard-Templates (Ziel der einmaligen Datei-Migration). */
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 final InvoiceTemplateRepository repository;
@@ -64,10 +70,28 @@ public class InvoiceTemplateService {
migrateLegacyTemplateIfMissing();
return repository.findAll().stream()
.map(template -> template.getName())
.filter(name -> !AUTOSAVE_NAME.equals(name))
.sorted(String.CASE_INSENSITIVE_ORDER)
.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. */
public Optional<String> loadTemplate(String 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.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@@ -48,6 +52,20 @@ public class TemplatePdfService {
JsonNode rootNode = mapper.readTree(jsonTemplateData);
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();
htmlBuilder.append("<!DOCTYPE html>");
htmlBuilder.append("<html><head>");
@@ -55,7 +73,8 @@ public class TemplatePdfService {
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
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(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -72,8 +91,22 @@ public class TemplatePdfService {
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();
HtmlConverter.convertToPdf(htmlBuilder.toString(), out);
HtmlConverter.convertToPdf(htmlBuilder.toString(), out, converterProperties);
return out.toByteArray();
}
@@ -103,6 +136,7 @@ public class TemplatePdfService {
int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14;
String color = element.has("color") ? element.get("color").asText("#333333") : "#333333";
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)
double mmX = xPercent / 100.0 * 210.0;
@@ -126,6 +160,7 @@ public class TemplatePdfService {
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("color:").append(color).append(";");
htmlBuilder.append("font-family:").append(cssFontStack(fontFamily)).append(";");
// services.list als Block, damit die Tabelle die Breite füllen kann
if ("services.list".equals(variable)) {
htmlBuilder.append("display:block;overflow:visible;padding:0;");
@@ -184,9 +219,9 @@ public class TemplatePdfService {
}
} else if ("services.list".equals(variable)) {
if (variables.containsKey("services.json")) {
htmlBuilder.append(generateServicesTableHtmlWithData(variables));
htmlBuilder.append(generateServicesTableHtmlWithData(variables, fontSize, mmHeight));
} else {
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate));
htmlBuilder.append(generateServicesTableHtml(effectiveVatRate, fontSize, mmHeight));
}
} else if (text.contains("<br>")) {
// Mehrzeiliger Text: ohne nowrap rendern, damit <br> wirkt
@@ -199,7 +234,7 @@ public class TemplatePdfService {
}
/** 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)
.stripTrailingZeros();
if (pct.scale() < 0) {
@@ -207,47 +242,23 @@ public class TemplatePdfService {
}
String vatLabel = pct.toPlainString().replace('.', ',') + "%";
String[][] sampleData = { { "Beratungsleistung", vatLabel, "450,00 €" },
{ "Softwareentwicklung", vatLabel, "1.200,00 €" }, { "Support-Pauschale", vatLabel, "150,00" } };
List<Map<String, String>> rows = List.of(
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 grossTotal = netTotal + (netTotal * vatRate.doubleValue());
StringBuilder html = new StringBuilder();
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(tableHeaderRow());
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();
double vatTotal = netTotal * vatRate.doubleValue();
return renderServicesTable(rows,
String.format(Locale.GERMANY, "%,.2f €", netTotal),
String.format(Locale.GERMANY, "%,.2f €", vatTotal),
String.format(Locale.GERMANY, "%,.2f €", netTotal + vatTotal),
vatLabel, fontSize, mmHeight);
}
/** Positionstabelle aus echten Daten (services.json + invoice.*-Summen). */
private String generateServicesTableHtmlWithData(Map<String, String> variables) {
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%");
private String generateServicesTableHtmlWithData(Map<String, String> variables, int fontSize, double mmHeight) {
List<Map<String, String>> servicesData = new ArrayList<>();
String servicesJson = variables.get("services.json");
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());
}
}
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();
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(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()) {
html.append("<tr style='border-bottom:1px solid #eeeeee;'>");
html.append(
"<td colspan='3' style='text-align:center;padding:4px 8px;white-space:nowrap;'>Keine Positionen vorhanden</td>");
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 headStyle = "background-color:#eeeeee;font-size:0.75em;font-weight:normal;color:#333333;"
+ "text-align:left;padding:2px 6px;white-space:nowrap;";
html.append("<tr>");
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>");
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;width:55%;'>")
.append(escapeHtml(name)).append("</td>");
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
.append(escapeHtml(rowVat)).append("</td>");
// € nur an rein numerische Beträge anhängen; Werte wie "15 %" unverändert
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + "" : escapeHtml(netAmount);
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
.append(amountDisplay).append("</td>");
html.append("</tr>");
}
if (rows.isEmpty()) {
html.append("<tr><td style='text-align:center;padding:2px 6px;'></td>")
.append("<td style='padding:2px 6px;border-left:").append(border)
.append(";'>Keine Positionen vorhanden</td>")
.append("<td style='border-left:").append(border).append(";'></td>")
.append("<td style='border-left:").append(border).append(";'></td></tr>");
}
for (Map<String, String> row : rows) {
html.append("<tr>");
html.append("<td style='text-align:center;padding:2px 6px;white-space:nowrap;'>")
.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>");
}
// 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("<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;'>");
// Bei gemischten USt-Sätzen (leeres Label) ohne Satzangabe beschriften
String vatSummaryLabel = vatRateLabel.isEmpty() ? "zzgl. USt:"
: "zzgl. " + escapeHtml(vatRateLabel) + " USt:";
html.append(summaryRow("Nettosumme:", netTotal, false));
html.append(summaryRow(vatSummaryLabel, vatTotal, false));
html.append(summaryRow("Gesamtsumme:", grossTotal, true));
html.append("<colgroup><col style='width:70%;'/><col style='width:15%;'/><col style='width:15%;'/>")
.append("</colgroup>");
html.append(summaryRow("Nettobetrag", escapeHtml(netTotal), true, border));
html.append(summaryRow(vatLabelText, escapeHtml(vatTotal), false, border));
html.append(summaryRow("Endbetrag", escapeHtml(grossTotal), true, border));
html.append("</table>");
html.append("</div>");
html.append("</div>");
return html.toString();
}
private String tableHeaderRow() {
return "<tr style='background-color:#f5f5f5;border-bottom:1px solid #cccccc;'>"
+ "<th style='text-align:left;padding:4px 8px;font-weight:bold;width:55%;white-space:nowrap;'>Name</th>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:20%;white-space:nowrap;'>Steuersatz</th>"
+ "<th style='text-align:right;padding:4px 8px;font-weight:bold;width:25%;white-space:nowrap;'>Nettobetrag</th>"
/** Eine Zeile des Summenblocks; jede zweite Zeile ist grau hinterlegt. */
private String summaryRow(String label, String value, boolean shaded, String border) {
String bg = shaded ? "background-color:#eeeeee;" : "";
return "<tr>"
+ "<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>";
}
private String summaryRow(String label, String value, boolean emphasized) {
String labelStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;" : "padding:2px 8px;";
String valueStyle = emphasized ? "padding:4px 8px;font-weight:bold;font-size:1.05em;"
: "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>"
+ "<td style='width:25%;text-align:right;white-space:nowrap;" + valueStyle + "'>" + value + "</td>"
+ "</tr>";
/** € nur an rein numerische Beträge anhängen; andere Werte escaped übernehmen. */
private String amountDisplay(String amount) {
return amount.matches("[0-9.,]+") ? amount + "" : escapeHtml(amount);
}
/**
* CSS-Schriftstapel zur Schriftart des Elements; nur bekannte Werte werden
* übernommen (Whitelist), alles andere fällt auf Arial zurück.
*/
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) {
@@ -38,6 +38,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item);
Map<String, String> position = new HashMap<>();
position.put("name", item.description());
position.put("quantity", quantityLabel(item.quantity()));
position.put("unitPrice", formatAmount(item.unitPriceNet()));
position.put("netAmount", formatAmount(net));
position.put("vat", vatLabel(item.vatPercent()));
positions.add(position);
@@ -71,6 +73,8 @@ public final class TemplateVariables {
BigDecimal net = lineNet(item);
Map<String, String> row = new HashMap<>();
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("net", formatAmount(net) + "");
rows.add(row);
@@ -97,6 +101,15 @@ public final class TemplateVariables {
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%". */
public static String vatLabel(BigDecimal percent) {
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.orderedlayout.HorizontalLayout;
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.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
@@ -38,6 +39,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -58,6 +60,13 @@ public class TemplateGeneratorView extends VerticalLayout {
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 InvoiceTemplateService invoiceTemplateService;
private final CapturedInvoiceData capturedInvoiceData;
@@ -70,6 +79,15 @@ public class TemplateGeneratorView extends VerticalLayout {
/** Name des zuletzt geladenen bzw. gespeicherten Templates. */
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,
InvoiceTemplateService invoiceTemplateService, CapturedInvoiceData capturedInvoiceData) {
this.templatePdfService = templatePdfService;
@@ -141,18 +159,67 @@ public class TemplateGeneratorView extends VerticalLayout {
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() {
List<String> names = invoiceTemplateService.templateNames();
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. */
@@ -164,6 +231,7 @@ public class TemplateGeneratorView extends VerticalLayout {
return;
}
currentTemplateName = name;
backgroundImage = readBackgroundImage(templateData.get());
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " window.loadProfileTemplate(JSON.parse($0)); "
@@ -234,6 +302,32 @@ public class TemplateGeneratorView extends VerticalLayout {
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() {
try {
return new ObjectMapper().writeValueAsString(buildVariables());
@@ -518,9 +612,13 @@ public class TemplateGeneratorView extends VerticalLayout {
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
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(() -> {
propertiesPanel.removeAll();
@@ -573,11 +671,13 @@ public class TemplateGeneratorView extends VerticalLayout {
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)) {
TextField textField = new TextField("Text");
TextArea textField = new TextArea("Text");
textField.setValue(text != null ? text : "");
textField.setWidthFull();
textField.setMinHeight("6em");
if (Boolean.TRUE.equals(isStatic)) {
textField.setReadOnly(true);
textField.setHelperText("Wert wird aus den Stammdaten befüllt");
@@ -589,40 +689,90 @@ public class TemplateGeneratorView extends VerticalLayout {
propertiesPanel.add(textField);
}
// X Position
TextField xField = new TextField("X Position");
xField.setValue(x != null ? String.valueOf(Math.round(x)) : "0");
// X Position (Anzeige in mm, Canvas rechnet intern in px)
TextField xField = new TextField("X Position (mm)");
xField.setValue(formatMm(x != null ? pxToMm(x, BASE_PAGE_WIDTH_PX, A4_WIDTH_MM) : 0));
xField.setWidthFull();
xField.addValueChangeListener(e -> {
try {
double newX = Double.parseDouble(e.getValue());
double newXPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_WIDTH_PX, A4_WIDTH_MM);
getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', $0, null); }",
newX);
newXPx);
} catch (NumberFormatException ignored) {
}
});
propertiesPanel.add(xField);
// Y Position
TextField yField = new TextField("Y Position");
yField.setValue(y != null ? String.valueOf(Math.round(y)) : "0");
// Y Position (Anzeige in mm, Canvas rechnet intern in px)
TextField yField = new TextField("Y Position (mm)");
yField.setValue(formatMm(y != null ? pxToMm(y, BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM) : 0));
yField.setWidthFull();
yField.addValueChangeListener(e -> {
try {
double newY = Double.parseDouble(e.getValue());
double newYPx = mmToPx(parseMm(e.getValue()), BASE_PAGE_HEIGHT_PX, A4_HEIGHT_MM);
getElement().executeJs(
"if (window.updateProfileElementPosition) { window.updateProfileElementPosition('"
+ elementId + "', null, $0); }",
newY);
newYPx);
} catch (NumberFormatException ignored) {
}
});
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)) {
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");
fontSizeField.setValue(fontSize != null ? String.valueOf(fontSize) : "16");
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
public void resetPropertiesPanel() {
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) {
return switch (variable) {
case "masterdata.company_name" -> "Firmenname des Rechnungsstellers";
@@ -778,7 +1015,7 @@ public class TemplateGeneratorView extends VerticalLayout {
showNotification("Zeichenfläche ist nicht bereit.");
return;
}
openSaveDialog(templateData);
openSaveDialog(withBackgroundImage(templateData));
});
}
@@ -831,6 +1068,8 @@ public class TemplateGeneratorView extends VerticalLayout {
private void doSaveTemplate(String name, String templateData) {
try {
invoiceTemplateService.saveTemplate(name, templateData);
// Explizit gespeichert: der Autosave-Arbeitsstand ist damit erledigt.
invoiceTemplateService.clearAutosave();
currentTemplateName = name;
templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setValue(name);
@@ -849,7 +1088,8 @@ public class TemplateGeneratorView extends VerticalLayout {
return;
}
try {
byte[] pdfBytes = templatePdfService.generatePdf(templateData, buildVariables(), VAT_RATE);
byte[] pdfBytes = templatePdfService.generatePdf(withBackgroundImage(templateData),
buildVariables(), VAT_RATE);
showPdfInDialog(pdfBytes);
} catch (Exception ex) {
showNotification("Vorschau konnte nicht erzeugt werden: " + ex.getMessage());