Initialer Stand des PDF Tools

ZUGFeRD-E-Rechnungs-Konverter mit Vaadin-UI: E-Rechnung aus PDF oder
Rechnungstemplate erzeugen, Template-Designer, Adressbuch und
speicherbare Formulareingaben (MongoDB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:02:54 +02:00
co-authored by Claude Fable 5
commit 960431914c
42 changed files with 5829 additions and 0 deletions
@@ -0,0 +1,48 @@
package de.assecutor.pdftool.invoice;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
class VatIdValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"DE261094748", // Assecutor Data Service GmbH
"DE136695976", // gültige Prüfziffer
"de 261 094 748", // Leerzeichen und Kleinschreibung werden normalisiert
"ATU12345678",
"NL123456789B01",
"FRXX123456789"
})
void accepts_valid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {
"DE261094749", // falsche Prüfziffer
"DE12345678", // zu kurz
"DE1234567890", // zu lang
"XX123456789", // unbekanntes Länderpräfix
"123456789", // Länderpräfix fehlt
"ATU1234567", // falsches Format für AT
"DE" // nur Präfix
})
void rejects_invalid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isPresent();
}
@Test
void rejects_blank_values() {
assertThat(VatIdValidator.validate(null)).isPresent();
assertThat(VatIdValidator.validate(" ")).isPresent();
}
@Test
void normalizes_separators_and_case() {
assertThat(VatIdValidator.normalize("de 261-094.748")).isEqualTo("DE261094748");
}
}
@@ -0,0 +1,63 @@
package de.assecutor.pdftool.template;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TemplatePdfServiceTest {
private final TemplatePdfService service = new TemplatePdfService();
@Test
void rendersCanvasTemplateWithVariablesAndPositionsToPdf() {
String template = """
{"elements": [
{"id": "e1", "type": "text", "variable": "masterdata.company_name",
"xPercent": 10, "yPercent": 5, "widthPercent": 40, "heightPercent": 3},
{"id": "e2", "type": "text", "text": "Freier Text",
"xPercent": 10, "yPercent": 10, "widthPercent": 40, "heightPercent": 3},
{"id": "e3", "type": "line", "xPercent": 10, "yPercent": 15, "widthPercent": 80, "heightPercent": 0},
{"id": "e4", "type": "text", "variable": "services.list",
"xPercent": 10, "yPercent": 20, "widthPercent": 80, "heightPercent": 20}
]}""";
Map<String, String> variables = Map.of(
"masterdata.company_name", "Assecutor Data Service GmbH",
"services.json",
"[{\"name\":\"Beratung\",\"netAmount\":\"100,00\",\"vat\":\"19%\"},"
+ "{\"name\":\"Fachbuch\",\"netAmount\":\"50,00\",\"vat\":\"7%\"}]",
"invoice.net_total", "150,00 €",
"invoice.vat_total", "22,50 €",
"invoice.gross_total", "172,50 €",
"invoice.vat_rate", "");
byte[] pdf = service.generatePdf(template, variables, new BigDecimal("0.19"));
assertTrue(pdf.length > 500, "PDF sollte Inhalt haben");
assertArrayEquals("%PDF-".getBytes(StandardCharsets.US_ASCII),
java.util.Arrays.copyOfRange(pdf, 0, 5), "Ergebnis muss ein PDF sein");
}
@Test
void legacyPixelCoordinatesAreConverted() {
String template = """
{"elements": [
{"id": "e1", "type": "text", "text": "Legacy", "x": 100, "y": 100, "width": 200, "height": 30}
]}""";
byte[] pdf = service.generatePdf(template, Map.of(), null);
assertTrue(pdf.length > 500);
}
@Test
void invalidJsonThrowsTemplateRenderException() {
assertThrows(TemplateRenderException.class,
() -> service.generatePdf("kein json", Map.of(), null));
}
}
@@ -0,0 +1,60 @@
package de.assecutor.pdftool.template;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TemplateVariablesTest {
private static InvoiceMetadata.LineItem item(String description, String quantity, String unitPrice, String vat) {
return new InvoiceMetadata.LineItem(description, new BigDecimal(quantity), new BigDecimal(unitPrice),
new BigDecimal(vat));
}
@Test
void addPositionsComputesTotalsFromQuantityAndUnitPrice() {
Map<String, String> variables = new HashMap<>();
TemplateVariables.addPositions(variables, List.of(
item("Beratung", "2", "100.00", "19"),
item("Fachbuch", "1", "50.00", "19")));
assertEquals("250,00 €", variables.get("invoice.net_total"));
assertEquals("47,50 €", variables.get("invoice.vat_total"));
assertEquals("297,50 €", variables.get("invoice.gross_total"));
assertEquals("19%", variables.get("invoice.vat_rate"));
assertEquals("2", variables.get("services.count"));
assertTrue(variables.get("services.json").contains("\"netAmount\":\"200,00\""),
"Nettobetrag der Position muss Menge x Einzelpreis sein");
}
@Test
void mixedVatRatesYieldEmptyUniformLabelAndPerRowVat() {
Map<String, String> variables = new HashMap<>();
TemplateVariables.addPositions(variables, List.of(
item("Beratung", "1", "100.00", "19"),
item("Fachbuch", "1", "50.00", "7")));
assertEquals("", variables.get("invoice.vat_rate"));
assertEquals("22,50 €", variables.get("invoice.vat_total"));
assertTrue(variables.get("services.json").contains("\"vat\":\"19%\""));
assertTrue(variables.get("services.json").contains("\"vat\":\"7%\""));
}
@Test
void canvasPositionsJsonContainsRowsAndTotals() {
String json = TemplateVariables.canvasPositionsJson(List.of(item("Beratung", "1", "100.00", "19")));
assertTrue(json.contains("\"rows\""));
assertTrue(json.contains("\"netTotal\":\"100,00 €\""));
assertTrue(json.contains("\"grossTotal\":\"119,00 €\""));
}
}
@@ -0,0 +1,133 @@
package de.assecutor.pdftool.zugferd;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ZugferdServiceTest {
private final ZugferdService service = new ZugferdService(new ZugferdValidationService());
@Test
void createsZipWithZugferdPdfAndFacturXml() throws Exception {
byte[] sourcePdf = createSamplePdf();
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
assertEquals("RE-2026-0815.zip", result.zipFileName());
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
Map<String, byte[]> entries = readZip(result.zip());
assertEquals(3, entries.size());
byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf");
byte[] xml = entries.get("factur-x.xml");
byte[] report = entries.get("validation-report.xml");
assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP");
assertNotNull(xml, "Factur-X-XML fehlt im ZIP");
assertNotNull(report, "Prüfbericht fehlt im ZIP");
String xmlText = new String(xml, StandardCharsets.UTF_8);
assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML");
assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML");
assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt");
// Das erzeugte PDF muss das XML als eingebettete Datei enthalten
try (PDDocument doc = Loader.loadPDF(pdf)) {
String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString();
assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names);
}
}
@Test
void convertsPdfWhosePageHasNoResources() throws Exception {
// Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab —
// der Service muss solche PDFs vorab reparieren.
byte[] sourcePdf;
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.addPage(new PDPage());
doc.save(out);
sourcePdf = out.toByteArray();
}
ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata());
assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport());
}
@Test
void rejectsNonPdfInput() {
assertThrows(ZugferdConversionException.class,
() -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata()));
}
private static InvoiceMetadata sampleMetadata() {
return new InvoiceMetadata(
"RE-2026-0815",
LocalDate.of(2026, 7, 8),
LocalDate.of(2026, 7, 1),
LocalDate.of(2026, 7, 22),
"EUR",
"Zahlbar innerhalb von 14 Tagen ohne Abzug.",
"KR-2026-042",
"DE75512108001245126199",
null,
new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14",
"21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com",
"+49 40 18 123 771 0"),
new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null,
"einkauf@example.com", null),
List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE,
new BigDecimal("1500.00"), new BigDecimal("19")))
);
}
private static byte[] createSamplePdf() throws Exception {
try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
// PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei
PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream(
"/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true);
cs.beginText();
cs.setFont(font, 12);
cs.newLineAtOffset(50, 700);
cs.showText("Rechnung RE-2026-0815");
cs.endText();
}
doc.save(out);
return out.toByteArray();
}
}
private static Map<String, byte[]> readZip(byte[] zip) throws Exception {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
entries.put(entry.getName(), zis.readAllBytes());
}
}
return entries;
}
}