Compare commits

..
12 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 cc129b9c23 Build- und Tooling-Dateien aktualisiert (Vaadin-generierte Dateien, VSCode-Einstellungen)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:06:42 +02:00
SvenandClaude Fable 5 dcf68fede6 PDF-Service-Aufrufe auf versionierte API umgestellt (/api/v1)
Die API-Version steckt in der Basis-URL (PDF_SERVICE_URL, Standard
http://localhost:8084/api/v1); der Client ruft relativ dazu /invoices/sign auf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:55:25 +02:00
SvenandClaude Fable 5 60aba03633 ZUGFeRD-Umwandlung an das PDF Service delegiert, lokale ZUGFeRD-Klassen entfernt
- PdfServiceClient ruft POST /api/invoices/sign auf (Basis-URL über PDF_SERVICE_URL,
  Standard http://localhost:8084); ZIP, Dateiname und Validierungsergebnis kommen
  aus der Response
- zugferd-Paket samt Test entfernt, Mustang-Abhängigkeiten aus der pom.xml gestrichen
- README und .env.example entsprechend angepasst

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:31:10 +02:00
SvenandClaude Fable 5 c95cd99be7 REST-API in das eigenständige Projekt "PDF Service" ausgelagert
InvoiceSigningController entfernt; die ZUGFeRD-Services bleiben für die
Weboberfläche erhalten. Multipart-Konfiguration entfällt (kein Upload mehr).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:22:40 +02:00
SvenandClaude Fable 5 fd71c272fa PDF-Upload aus der Rechnungsmaske entfernt; Rechnungen entstehen in der UI nur noch aus Templates
Die REST-API (/api/invoices/sign, /validate) nimmt unverändert PDFs entgegen
und bietet weiterhin keinen Template-Zugriff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:31:29 +02:00
SvenandClaude Fable 5 fa0d9da2b1 Beispiel-Hinweise unter den Eingabefeldern entfernt (USt-IdNr., Webseite, IBAN, BIC)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:21:32 +02:00
SvenandClaude Fable 5 28f62cee06 Telefon und Webseite des Rechnungsstellers: Adressbuch-Feld, Formularfelder, Belegung von masterdata.phone/website
- Adressbuch: neues Feld "Webseite" pro Unternehmen
- Rechnungsmaske: Telefon und Webseite beim Rechnungssteller manuell erfassbar, Vorbelegung aus dem Adressbuch
- masterdata.phone/masterdata.website werden aus den Formularfeldern belegt, mit Adressbuch-Fallback (bisher immer leer)
- Webseite wird in Vorbelegungen mitgespeichert, Telefon fließt über Party.phone in die ZUGFeRD-Daten

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:13:40 +02:00
SvenandClaude Fable 5 6838ce3b20 Rechnungsmaske: ZIP-Auto-Download, löschbare Vorbelegungen, Icon- und Textausrichtungs-Korrekturen
- Valides ZIP startet den Download direkt; der sichtbare Download-Link entfällt
- Vorbelegung laden: Löschen-Icon rechts an jedem Eintrag, Löschung aus MongoDB nach Sicherheitsabfrage
- Template-Wechsel leert die Rechnungssteller-Felder nicht mehr, damit "Eingaben speichern" aktiv wird
- Aktionsspalten der Grids mit fester Breite, damit die Icons nicht abgeschnitten werden
- Texte im PDF-Renderer und Designer-Canvas oben statt zentriert ausgerichtet, damit mehrzeilige Variablenwerte auf Höhe ihrer Nachbar-Elemente beginnen
- Vaadin-Frontend-Scaffolding (package.json, tsconfig, vite-Konfiguration) aufgenommen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:56:30 +02:00
SvenandClaude Fable 5 5bcaf97eba Dockerfile: Microsoft Core Fonts und DejaVu für den PDF-Renderer installieren
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:08:24 +02:00
SvenandClaude Fable 5 4d16455d17 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>
2026-07-16 14:08:16 +02:00
SvenandClaude Fable 5 9891a58fad Schutz vor stillen Seiten-Reloads: 12h-Session, eigener Session-Cookie, Meldung statt Sofort-Reload
Session-Timeout auf 12 h erhöht, Cookie-Name PDFTOOL_SESSION gegen
JSESSIONID-Kollisionen anderer lokaler Java-Anwendungen, Tomcat-POST-Limit auf
30 MB für Canvas-Daten mit eingebetteten Bildern, Vaadin-Systemmeldungen mit
Bestätigung statt sofortigem Neuladen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:08:06 +02:00
SvenandClaude Fable 5 48a5831600 Bankverbindung (Bank, IBAN, BIC) im Adressbuch, IBAN/BIC über den Rechnungssteller statt Freitext
IBAN-Prüfung (ISO 13616) in eigenen IbanValidator ausgelagert; Käuferreferenz
und dev-Testdaten aus der Rechnungsmaske entfernt, Entwurf-Speichern nur bei
gefüllten Pflichtfeldern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:07:56 +02:00
35 changed files with 12134 additions and 1368 deletions
+4
View File
@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admi
# HTTP-Port der Anwendung (Standard: 8083) # HTTP-Port der Anwendung (Standard: 8083)
SERVER_PORT=8083 SERVER_PORT=8083
# Basis-URL der PDF-Service-API (ZUGFeRD-Umwandlung); die Endpoints
# /invoices/sign und /invoices/validate liegen unterhalb dieser URL.
PDF_SERVICE_URL=http://localhost:8084/api/v1
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "disabled"
}
+8
View File
@@ -7,6 +7,14 @@ ENV TZ=Europe/Berlin
ENV LC_TIME=de_DE.UTF-8 ENV LC_TIME=de_DE.UTF-8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Schriften für den PDF-Renderer: Microsoft Core Fonts (Arial, Times New Roman,
# Courier New, Verdana) sowie DejaVu als Fallback. Die EULA der MS-Fonts wird
# vorab bestätigt; der Installer lädt die Fonts beim Image-Build herunter.
RUN echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections \
&& apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-dejavu-core ttf-mscorefonts-installer \
&& rm -rf /var/lib/apt/lists/*
COPY ${JAR_FILE} app.jar COPY ${JAR_FILE} app.jar
EXPOSE 8083 EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"] ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=production"]
+18 -90
View File
@@ -1,7 +1,7 @@
# PDF Tool — ZUGFeRD E-Rechnungs-Konverter # PDF Tool — ZUGFeRD E-Rechnungs-Konverter
Spring Boot + Vaadin Flow Anwendung, die ein herkömmliches B2B-Rechnungs-PDF in eine Spring Boot + Vaadin Flow Anwendung, die aus gespeicherten Rechnungstemplates
**rechtskonforme ZUGFeRD-E-Rechnung** umwandelt (PDF/A-3 mit eingebettetem EN16931-XML, **rechtskonforme ZUGFeRD-E-Rechnungen** erzeugt (PDF/A-3 mit eingebettetem EN16931-XML,
Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als
**ZIP-Datei** bereitstellt. **ZIP-Datei** bereitstellt.
@@ -26,7 +26,8 @@ mvn spring-boot:run
``` ```
Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar: Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar:
PDF hochladen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken, ZIP herunterladen. Rechnungstemplate auswählen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken
das ZIP wird automatisch heruntergeladen.
Produktions-Build: Produktions-Build:
@@ -35,94 +36,21 @@ mvn clean package -Pproduction
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
``` ```
## REST-API ## PDF Service
### E-Rechnung erzeugen Die ZUGFeRD-Umwandlung (PDF/A-3 + EN16931-XML, Mustangproject) übernimmt das
eigenständige Projekt **PDF Service** (`../PDF Service`, Port 8084). Dieses Tool
`POST /api/invoices/sign` (multipart/form-data) mit zwei Parts: rendert die Rechnung aus dem Template und ruft dann dessen REST-API
(`POST /api/v1/invoices/sign`) auf — der Service muss dafür laufen. Die Basis-URL
| Part | Inhalt | der API ist über `PDF_SERVICE_URL` konfigurierbar (Standard
|------------|-------------------------------------------| `http://localhost:8084/api/v1`).
| `file` | das Rechnungs-PDF |
| `metadata` | Rechnungsdaten als JSON (siehe unten) |
Antwort: `application/zip` mit `<Rechnungsnummer>-zugferd.pdf`, `factur-x.xml` und
`validation-report.xml` (Mustang-Prüfbericht). Der Header `X-Zugferd-Valid: true|false`
meldet das Validierungsergebnis. Bei Fehlern: HTTP 400 mit `{"error": "..."}`.
> Häufigste Ursache für `X-Zugferd-Valid: false`: Das Quell-PDF bettet seine
> Schriften nicht ein. Das kann die Konvertierung nicht reparieren — das PDF muss
> dann mit eingebetteten Schriften neu erzeugt werden (Standard bei den meisten
> ERP-/Reporting-Systemen konfigurierbar).
### Bestehende E-Rechnung prüfen
`POST /api/invoices/validate` (multipart/form-data, Part `file` = ZUGFeRD-PDF oder
Factur-X-XML). Antwort: der Mustang-Prüfbericht als XML — HTTP 200 bei gültiger,
HTTP 422 bei ungültiger Rechnung, ebenfalls mit `X-Zugferd-Valid`-Header.
```bash
curl -X POST http://localhost:8083/api/invoices/validate \
-F "file=@rechnung-zugferd.pdf"
```
### Beispiel
```bash
curl -X POST http://localhost:8083/api/invoices/sign \
-F "file=@rechnung.pdf" \
-F "metadata=@metadata.json;type=application/json" \
-o rechnung-zugferd.zip
```
`metadata.json`:
```json
{
"invoiceNumber": "RE-2026-0815",
"issueDate": "2026-07-08",
"deliveryDate": "2026-07-01",
"dueDate": "2026-07-22",
"currency": "EUR",
"paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.",
"sender": {
"name": "Assecutor GmbH",
"street": "Musterstrasse 1",
"zip": "20095",
"city": "Hamburg",
"countryCode": "DE",
"vatId": "DE123456789"
},
"recipient": {
"name": "Kunde AG",
"street": "Beispielweg 2",
"zip": "10115",
"city": "Berlin",
"countryCode": "DE"
},
"items": [
{
"description": "Beratungsleistung",
"quantity": 1,
"unitPriceNet": 1500.00,
"vatPercent": 19
}
]
}
```
Pflichtfelder: `invoiceNumber`, `issueDate`, `sender` (inkl. Adresse), `recipient`,
mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum zurück,
`dueDate` auf Rechnungsdatum + 14 Tage, `currency` auf `EUR`.
## Architektur ## Architektur
- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik: - [TemplatePdfService](src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java) — rendert
PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP. die Canvas-Templates (JSON aus MongoDB) über iText html2pdf zu einem PDF
Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert. - [PdfServiceClient](src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java) — ruft den
- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator PDF Service auf, der daraus die ZUGFeRD-E-Rechnung als ZIP erzeugt
(XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht
- [InvoiceSigningController](src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java) — REST-Endpunkte `/sign` und `/validate`
- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche - [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche
- [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten - [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten
@@ -132,6 +60,6 @@ mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum
mvn test mvn test
``` ```
[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein Die Tests decken das Template-Rendering ([TemplatePdfServiceTest](src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java),
Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem [TemplateVariablesTest](src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java)) und die
`factur-x.xml` (EN16931-Profil) enthält. USt-IdNr.-Validierung ab; die ZUGFeRD-Umwandlung wird im Projekt PDF Service getestet.
Vendored
+189 -189
View File
@@ -1,189 +1,189 @@
<# : batch portion <# : batch portion
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one @REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file @REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information @REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file @REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the @REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance @REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at @REM with the License. You may obtain a copy of the License at
@REM @REM
@REM http://www.apache.org/licenses/LICENSE-2.0 @REM http://www.apache.org/licenses/LICENSE-2.0
@REM @REM
@REM Unless required by applicable law or agreed to in writing, @REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an @REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the @REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations @REM specific language governing permissions and limitations
@REM under the License. @REM under the License.
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM @REM
@REM Optional ENV vars @REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution @REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__= @SET __MVNW_CMD__=
@SET __MVNW_ERROR__= @SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% @SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath= @SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
) )
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% @SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE= @SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__= @SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME= @SET MVNW_USERNAME=
@SET MVNW_PASSWORD= @SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1 @echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF @GOTO :EOF
: end batch / begin powershell #> : end batch / begin powershell #>
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") { if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue" $VerbosePreference = "Continue"
} }
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) { if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
} }
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" { "maven-mvnd-*" {
$USE_MVND = $true $USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd" $MVN_CMD = "mvnd.cmd"
break break
} }
default { default {
$USE_MVND = $false $USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn' $MVN_CMD = $script -replace '^mvnw','mvn'
break break
} }
} }
# apply MVNW_REPOURL and calculate MAVEN_HOME # apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash> # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) { if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
} }
$distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2" $MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) { if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
} }
if (-not (Test-Path -Path $MAVEN_M2_PATH)) { if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
} }
$MAVEN_WRAPPER_DISTS = $null $MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else { } else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
} }
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" $MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $? exit $?
} }
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
} }
# prepare tmp dir # prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap { trap {
if ($TMP_DOWNLOAD_DIR.Exists) { if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
} }
} }
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven # Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl" Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient $webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
} }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file # If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) { if ($distributionSha256Sum) {
if ($USE_MVND) { if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
} }
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
} }
} }
# unzip and move # unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name) # Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = "" $actualDistributionDir = ""
# First try the expected directory name (for regular distributions) # First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" $expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" $expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain $actualDistributionDir = $distributionUrlNameMain
} }
# If not found, search for any directory with the Maven executable (for snapshots) # If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) { if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD" $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) { if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name $actualDistributionDir = $_.Name
} }
} }
} }
if (!$actualDistributionDir) { if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive" Write-Error "Could not find Maven distribution directory in extracted archive"
} }
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try { try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch { } catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME" Write-Error "fail to move MAVEN_HOME"
} }
} finally { } finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
} }
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+9537
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.7.6",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.7.6",
"@vaadin/react-components": "24.7.6",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.6",
"@vaadin/vaadin-material-styles": "24.7.6",
"@vaadin/vaadin-themable-mixin": "24.7.6",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.5.2"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@vitejs/plugin-react": "4.4.1",
"async": "3.2.6",
"glob": "11.0.2",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.3.4",
"vite-plugin-checker": "0.9.1",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.7.6",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/polymer-legacy-adapter": "24.7.6",
"@vaadin/react-components": "24.7.6",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.6",
"@vaadin/vaadin-material-styles": "24.7.6",
"@vaadin/vaadin-themable-mixin": "24.7.6",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
"lit": "3.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.5.2"
},
"devDependencies": {
"@babel/preset-react": "7.26.3",
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@vitejs/plugin-react": "4.4.1",
"async": "3.2.6",
"glob": "11.0.2",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.7.3",
"vite": "6.3.4",
"vite-plugin-checker": "0.9.1",
"workbox-build": "7.3.0",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
},
"hash": "79560e4ddc395b0ad64481d9a3f56513be65cec5fb700f13f4c25c4dd181fa94"
},
"overrides": {
"@vaadin/bundles": "$@vaadin/bundles",
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
"@vaadin/react-components": "$@vaadin/react-components",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"react-dom": "$react-dom",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@polymer/polymer": "$@polymer/polymer",
"react": "$react",
"react-router": "$react-router",
"date-fns": "$date-fns",
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
}
}
-13
View File
@@ -20,7 +20,6 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<vaadin.version>24.7.4</vaadin.version> <vaadin.version>24.7.4</vaadin.version>
<mustang.version>2.24.0</mustang.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -54,13 +53,6 @@
<artifactId>spring-boot-starter-data-mongodb</artifactId> <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency> </dependency>
<!-- Mustangproject: Open-Source-Referenzimplementierung von ZUGFeRD / Factur-X -->
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>${mustang.version}</version>
</dependency>
<!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators <!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators
(HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. --> (HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. -->
<dependency> <dependency>
@@ -68,11 +60,6 @@
<artifactId>html2pdf</artifactId> <artifactId>html2pdf</artifactId>
<version>5.0.5</version> <version>5.0.5</version>
</dependency> </dependency>
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>validator</artifactId>
<version>${mustang.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
Binary file not shown.
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'
); );
} }
} }
@@ -130,6 +165,39 @@ window.initProfileInvoiceGenerator = function() {
window.invoiceGeneratorViewProfile.$server.resetPropertiesPanel(); window.invoiceGeneratorViewProfile.$server.resetPropertiesPanel();
} }
} }
// 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() {
@@ -155,24 +223,22 @@ window.initProfileInvoiceGenerator = function() {
ctx.strokeStyle = '#cccccc'; ctx.strokeStyle = '#cccccc';
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
console.log('Drawing', elements.length, 'elements'); console.log('Drawing', elements.length, 'elements');
elements.forEach(function(el) { elements.forEach(function(el) {
@@ -184,6 +250,8 @@ window.initProfileInvoiceGenerator = function() {
if (selectedElement) { if (selectedElement) {
drawSelection(selectedElement); drawSelection(selectedElement);
} }
scheduleAutosave();
} }
function drawElement(el) { function drawElement(el) {
@@ -277,28 +345,41 @@ window.initProfileInvoiceGenerator = function() {
// Text elements // Text elements
var lines = (el.text || '').split('\n'); var lines = (el.text || '').split('\n');
var lineHeight = fontSize * 1.2; var lineHeight = fontSize * 1.2;
var totalTextHeight = lines.length * lineHeight;
// Oben ausrichten statt zentrieren — wie im gerenderten PDF, damit die
// erste Zeile unabhängig von der Zeilenzahl an der Box-Oberkante beginnt
var ty = y;
// Vertically center the text in the element // Masterdata elements are never bold; other elements respect their fontStyle
var ty = y + (h - totalTextHeight) / 2; var fontWeight = (el.isStatic && !el.isCustomer) ? '' : (el.fontStyle || '');
// Set the font BEFORE measuring text, so the highlight width matches
// Draw background highlight for static elements 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)
ctx.fillStyle = '#f5f5f5';
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.font = 'bold ' + fontSize + 'px Arial';
ctx.textBaseline = 'middle';
// Name column header (left-aligned)
ctx.textAlign = 'left';
ctx.fillText('Name', colNameX + padding, y + rowHeight / 2);
// Steuersatz column header (right-aligned)
ctx.textAlign = 'right';
ctx.fillText('Steuersatz', colVatX + colVatWidth - padding, y + rowHeight / 2);
// Nettobetrag column header (right-aligned)
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
var currentY = y + rowHeight;
// Draw data rows // Kopfzeile: graue Flaeche mit kleinen Beschriftungen
ctx.font = fontSize + 'px Arial'; ctx.fillStyle = '#eeeeee';
rows.forEach(function(row, index) { ctx.fillRect(x, y, w, rowHeight);
// Draw row background (alternating)
if (index % 2 === 1) { ctx.fillStyle = '#333333';
ctx.fillStyle = 'rgba(0,0,0,0.02)'; ctx.font = (fontSize * 0.75) + 'px ' + fontFamily;
ctx.fillRect(x, currentY, w, rowHeight); ctx.textBaseline = 'middle';
} ctx.textAlign = 'left';
ctx.fillText('Menge', x + padding, y + rowHeight / 2);
// Row bottom border ctx.fillText('Bezeichnung', xName + padding, y + rowHeight / 2);
ctx.strokeStyle = '#eeeeee'; ctx.fillText('Einzelpreis', xUnit + padding, y + rowHeight / 2);
ctx.lineWidth = Math.max(0.5, zoomFactor * 0.5); ctx.fillText('Gesamt', xTotal + padding, y + rowHeight / 2);
ctx.beginPath();
ctx.moveTo(x, currentY + rowHeight); // Datenzeilen
ctx.lineTo(x + w, currentY + rowHeight); var currentY = y + rowHeight;
ctx.stroke(); ctx.font = fontSize + 'px ' + fontFamily;
rows.forEach(function(row) {
// 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,43 +495,43 @@ 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) {
// Nettosumme - label left, value right if (row.shaded) {
ctx.fillStyle = '#333333'; ctx.fillStyle = '#eeeeee';
ctx.font = fontSize + 'px Arial'; ctx.fillRect(xUnit, summaryY, x + w - xUnit, summaryRowHeight);
ctx.textAlign = 'left'; }
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2); // Fortsetzung der Spaltenlinie links neben den Summen-Labels
ctx.font = 'bold ' + fontSize + 'px Arial'; // (gleiche eingerastete X-Koordinate wie die Spaltenlinie darueber)
ctx.textAlign = 'right'; ctx.strokeStyle = '#000000';
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2); ctx.lineWidth = 1;
summaryY += summaryRowHeight; ctx.beginPath();
ctx.moveTo(Math.round(xUnit) + 0.5, summaryY);
ctx.lineTo(Math.round(xUnit) + 0.5, summaryY + summaryRowHeight);
ctx.stroke();
// Umsatzsteuer - label left, value right ctx.fillStyle = '#333333';
ctx.font = fontSize + 'px Arial'; ctx.font = fontSize + 'px ' + fontFamily;
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', 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(row.value, x + w - padding, summaryY + summaryRowHeight / 2);
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2); summaryY += summaryRowHeight;
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) {
var x = pageX + (el.x * zoomFactor); var x = pageX + (el.x * zoomFactor);
var y = pageY + (el.y * zoomFactor); var y = pageY + (el.y * zoomFactor);
@@ -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,44 +606,60 @@ 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;
// For services.list, calculate table height
if (el.variable === 'services.list') {
var lineHeight = fontSize * 1.4;
var padding = 4 * zoomFactor;
var rowHeight = lineHeight + padding * 2;
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5;
var summaryHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
eh = Math.max(eh, tableHeight + summaryHeight);
} else {
var lines = (el.text || '').split('\n');
var maxLineWidth = 0;
lines.forEach(function(line) {
var lineWidth = ctx.measureText(line).width;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
});
ew = Math.max(ew, maxLineWidth + (10 * zoomFactor));
var lineHeight = fontSize * 1.2;
var textHeight = lines.length * lineHeight;
eh = Math.max(eh, textHeight + (6 * zoomFactor));
}
} }
if (el.type === 'image') {
return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh; return x >= ex && x <= ex + ew && y >= ey && y <= ey + eh;
}
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') {
var lineHeight = fontSize * 1.4;
var padding = 4 * zoomFactor;
var rowHeight = lineHeight + padding * 2;
var tableHeight = rowHeight + 3 * rowHeight; // Header + 3 rows
var summaryRowHeight = fontSize * 1.6;
var summaryGap = fontSize * 0.5;
var summaryHeight = 3 * summaryRowHeight + summaryGap;
eh = Math.max(eh, tableHeight + summaryHeight);
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 maxLineWidth = 0;
lines.forEach(function(line) {
maxLineWidth = Math.max(maxLineWidth, ctx.measureText(line).width);
});
var textHeight = lines.length * fontSize * 1.2;
var textAlign = el.textAlign || 'left';
var contentX = ex;
if (textAlign === 'center') {
contentX = ex + (ew - maxLineWidth) / 2;
} else if (textAlign === 'right') {
contentX = ex + ew - maxLineWidth;
}
var contentY = ey + (eh - textHeight) / 2;
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();
@@ -724,47 +776,53 @@ window.initProfileInvoiceGenerator = function() {
// Calculate delta in base coordinates // Calculate delta in base coordinates
var dx = (x - resizeStart.x) / zoomFactor; var dx = (x - resizeStart.x) / zoomFactor;
var dy = (y - resizeStart.y) / zoomFactor; var dy = (y - resizeStart.y) / zoomFactor;
var newWidth = resizeStart.width; var newWidth = resizeStart.width;
var newHeight = resizeStart.height; var newHeight = resizeStart.height;
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,7 +830,8 @@ 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);
} else if (isDragging && selectedElement) { } else if (isDragging && 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,10 +1296,18 @@ 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;
@@ -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);
@@ -1386,7 +1491,11 @@ 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();
@@ -25,6 +25,12 @@ public class Address {
private String vatId; private String vatId;
private String email; private String email;
private String phone; private String phone;
private String website;
// Bankverbindung (v. a. für Rechnungssteller relevant)
private String bankName;
private String iban;
private String bic;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -114,6 +120,38 @@ public class Address {
this.phone = phone; this.phone = phone;
} }
public String getWebsite() {
return website;
}
public void setWebsite(String website) {
this.website = website;
}
public String getBankName() {
return bankName;
}
public void setBankName(String bankName) {
this.bankName = bankName;
}
public String getIban() {
return iban;
}
public void setIban(String iban) {
this.iban = iban;
}
public String getBic() {
return bic;
}
public void setBic(String bic) {
this.bic = bic;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -1,122 +0,0 @@
package de.assecutor.pdftool.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
import de.assecutor.pdftool.zugferd.ZugferdResult;
import de.assecutor.pdftool.zugferd.ZugferdService;
import de.assecutor.pdftool.zugferd.ZugferdValidationService;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen.
*
* <pre>
* curl -X POST http://localhost:8083/api/invoices/sign \
* -F "file=@rechnung.pdf" \
* -F "metadata=@metadata.json;type=application/json" \
* -o rechnung-zugferd.zip
*
* curl -X POST http://localhost:8083/api/invoices/validate \
* -F "file=@rechnung-zugferd.pdf"
* </pre>
*/
@RestController
@RequestMapping("/api/invoices")
public class InvoiceSigningController {
/** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */
public static final String VALIDATION_HEADER = "X-Zugferd-Valid";
private final ZugferdService zugferdService;
private final ZugferdValidationService validationService;
private final ObjectMapper objectMapper;
private final Validator validator;
public InvoiceSigningController(ZugferdService zugferdService,
ZugferdValidationService validationService,
ObjectMapper objectMapper,
Validator validator) {
this.zugferdService = zugferdService;
this.validationService = validationService;
this.objectMapper = objectMapper;
this.validator = validator;
}
/**
* Erzeugt aus PDF und Metadaten eine ZUGFeRD-Rechnung als ZIP (PDF + Prüfbericht).
* HTTP 200 bei gültiger, HTTP 422 bei ungültiger Rechnung; das ZIP wird in
* beiden Fällen geliefert.
*/
@PostMapping(value = "/sign", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> sign(@RequestPart("file") MultipartFile file,
@RequestPart("metadata") String metadataJson) throws IOException {
InvoiceMetadata metadata = parseAndValidate(metadataJson);
ZugferdResult result = zugferdService.createZugferdZip(file.getBytes(), metadata);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/zip"));
headers.setContentDisposition(ContentDisposition.attachment().filename(result.zipFileName()).build());
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.zip(), headers,
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
}
/**
* Validiert eine bestehende ZUGFeRD-Rechnung (PDF oder Factur-X-XML).
* Antwort ist der Mustang-Prüfbericht als XML; HTTP 200 bei gültiger,
* HTTP 422 bei ungültiger Rechnung.
*/
@PostMapping(value = "/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> validate(@RequestPart("file") MultipartFile file) throws IOException {
ZugferdValidationService.ValidationResult result =
validationService.validate(file.getBytes(), file.getOriginalFilename());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.reportXml(), headers,
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
}
private InvoiceMetadata parseAndValidate(String metadataJson) {
InvoiceMetadata metadata;
try {
metadata = objectMapper.readValue(metadataJson, InvoiceMetadata.class);
} catch (IOException e) {
throw new ZugferdConversionException("Metadaten sind kein gültiges JSON: " + e.getMessage(), e);
}
Set<ConstraintViolation<InvoiceMetadata>> violations = validator.validate(metadata);
if (!violations.isEmpty()) {
String details = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.collect(Collectors.joining("; "));
throw new ZugferdConversionException("Metadaten unvollständig: " + details);
}
return metadata;
}
@ExceptionHandler(ZugferdConversionException.class)
public ResponseEntity<Map<String, String>> handleConversionError(ZugferdConversionException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@@ -0,0 +1,29 @@
package de.assecutor.pdftool.invoice;
/**
* Prüft IBANs nach ISO 13616 (MOD 97-10).
*/
public final class IbanValidator {
private IbanValidator() {
}
public static boolean isValid(String value) {
String normalized = normalize(value);
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
int mod = 0;
for (char c : rearranged.toCharArray()) {
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
}
return mod == 1;
}
/** Entfernt Leerzeichen und wandelt in Großbuchstaben um. */
public static String normalize(String value) {
return value.replaceAll("\\s", "").toUpperCase();
}
}
@@ -27,6 +27,7 @@ public record InvoiceDraft(
String buyerReference, String buyerReference,
String iban, String iban,
String bic, String bic,
String website,
InvoiceMetadata.Party sender, InvoiceMetadata.Party sender,
InvoiceMetadata.Party recipient, InvoiceMetadata.Party recipient,
List<InvoiceMetadata.LineItem> items, List<InvoiceMetadata.LineItem> items,
@@ -36,6 +36,11 @@ public class InvoiceDraftService {
return repository.existsByName(name); return repository.existsByName(name);
} }
/** Löscht die unter dem Namen gespeicherten Eingaben; ohne Treffer passiert nichts. */
public void delete(String name) {
repository.findByName(name).ifPresent(existing -> repository.deleteById(existing.id()));
}
/** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */ /** @return die Namen aller gespeicherten Eingaben, alphabetisch sortiert. */
public List<String> names() { public List<String> names() {
return repository.findAll().stream() return repository.findAll().stream()
@@ -0,0 +1,136 @@
package de.assecutor.pdftool.pdfservice;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* Client für die REST-API des PDF Service (eigenständiges Projekt), der aus
* einem Rechnungs-PDF und den Metadaten die ZUGFeRD-E-Rechnung als ZIP erzeugt
* ({@code POST {pdfservice.url}/invoices/sign}, Standard-Basis
* {@code http://localhost:8084/api/v1}).
*/
@Service
public class PdfServiceClient {
/** Response-Header des PDF Service mit dem Ergebnis der Mustang-Validierung. */
private static final String VALIDATION_HEADER = "X-Zugferd-Valid";
private final RestClient restClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
/** Ergebnis der Umwandlung: das ZIP, sein Dateiname und das Validierungsergebnis. */
public record SignResult(byte[] zip, String zipFileName, boolean valid) {
}
public PdfServiceClient(ObjectMapper objectMapper,
@Value("${pdfservice.url:http://localhost:8084/api/v1}") String baseUrl) {
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(5));
// Die Umwandlung (PDF/A-Konvertierung + Validierung) kann einige Sekunden dauern
requestFactory.setReadTimeout(Duration.ofSeconds(120));
this.restClient = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
}
/**
* Wandelt das PDF über den PDF Service in eine ZUGFeRD-E-Rechnung um. Das
* ZIP wird auch bei fehlgeschlagener Validierung (HTTP 422) geliefert;
* {@link SignResult#valid()} meldet das Ergebnis.
*
* @throws PdfServiceException wenn der Service nicht erreichbar ist oder
* die Anfrage ablehnt (z. B. HTTP 400)
*/
public SignResult sign(byte[] pdf, InvoiceMetadata metadata) {
String metadataJson;
try {
metadataJson = objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
throw new PdfServiceException("Die Rechnungsdaten konnten nicht serialisiert werden: "
+ e.getMessage(), e);
}
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("file", new ByteArrayResource(pdf) {
@Override
public String getFilename() {
return "rechnung.pdf";
}
});
HttpHeaders metadataHeaders = new HttpHeaders();
metadataHeaders.setContentType(MediaType.APPLICATION_JSON);
parts.add("metadata", new HttpEntity<>(metadataJson, metadataHeaders));
ResponseEntity<byte[]> response;
try {
response = restClient.post()
.uri("/invoices/sign")
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve()
// 422 = ungültige Rechnung, das ZIP kommt trotzdem mit
.onStatus(status -> status == HttpStatus.UNPROCESSABLE_ENTITY, (request, res) -> {
})
.onStatus(org.springframework.http.HttpStatusCode::isError, (request, res) -> {
throw new PdfServiceException(errorMessage(res.getStatusCode().value(),
new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8)));
})
.toEntity(byte[].class);
} catch (ResourceAccessException e) {
throw new PdfServiceException("Der PDF Service ist nicht erreichbar (" + baseUrl + "): "
+ e.getMessage(), e);
}
byte[] zip = response.getBody();
if (zip == null || zip.length == 0) {
throw new PdfServiceException("Der PDF Service hat ein leeres ZIP geliefert.");
}
boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER));
return new SignResult(zip, zipFileName(response.getHeaders(), metadata), valid);
}
/** Dateiname aus dem Content-Disposition-Header, sonst aus der Rechnungsnummer. */
private static String zipFileName(HttpHeaders headers, InvoiceMetadata metadata) {
ContentDisposition disposition = headers.getContentDisposition();
if (disposition.getFilename() != null && !disposition.getFilename().isBlank()) {
return disposition.getFilename();
}
return metadata.invoiceNumber() + "-zugferd.zip";
}
/** Fehlermeldung aus dem JSON-Body ({@code {"error": "..."}}) des PDF Service. */
private String errorMessage(int status, String body) {
try {
Map<?, ?> json = objectMapper.readValue(body, Map.class);
Object error = json.get("error");
if (error != null) {
return "Der PDF Service hat die Anfrage abgelehnt: " + error;
}
} catch (Exception ignored) {
// Body war kein JSON — Rohtext verwenden
}
return "Der PDF Service hat die Anfrage abgelehnt (HTTP " + status + "): " + body;
}
}
@@ -0,0 +1,13 @@
package de.assecutor.pdftool.pdfservice;
/** Fehler beim Aufruf des PDF Service (nicht erreichbar oder Ablehnung der Anfrage). */
public class PdfServiceException extends RuntimeException {
public PdfServiceException(String message) {
super(message);
}
public PdfServiceException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -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,11 +160,15 @@ 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;");
} else { } else {
htmlBuilder.append("display:flex;align-items:center;"); // Oben ausrichten statt zentrieren: Die erste Zeile muss an der
// Box-Oberkante beginnen, sonst verschiebt sich mehrzeiliger
// Variablentext gegenüber einzeiligen Nachbar-Elementen.
htmlBuilder.append("display:flex;align-items:flex-start;");
switch (textAlign) { switch (textAlign) {
case "center": case "center":
htmlBuilder.append("justify-content:center;text-align:center;"); htmlBuilder.append("justify-content:center;text-align:center;");
@@ -184,9 +222,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 +237,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 +245,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 +272,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("</tr>"); html.append("<th style='").append(headStyle).append("border-left:").append(border)
} else { .append(";'>Bezeichnung</th>");
for (int i = 0; i < servicesData.size(); i++) { html.append("<th style='").append(headStyle).append("border-left:").append(border)
Map<String, String> service = servicesData.get(i); .append(";'>Einzelpreis</th>");
String name = service.getOrDefault("name", "Unbekannte Position"); html.append("<th style='").append(headStyle).append("border-left:").append(border).append(";'>Gesamt</th>");
String netAmount = service.getOrDefault("netAmount", "0,00"); html.append("</tr>");
// 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
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>");
}
} }
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("</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();
@@ -20,6 +20,7 @@ import com.vaadin.flow.router.Route;
import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType; import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.IbanValidator;
import de.assecutor.pdftool.invoice.VatIdValidator; import de.assecutor.pdftool.invoice.VatIdValidator;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -84,6 +85,8 @@ public class AddressBookView extends VerticalLayout {
.setAutoWidth(true); .setAutoWidth(true);
grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true); grid.addColumn(address -> address.getName()).setHeader("Firma / Name").setSortable(true).setAutoWidth(true);
grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true); grid.addColumn(address -> address.getStreet()).setHeader("Straße").setAutoWidth(true);
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
grid.addComponentColumn(address -> { grid.addComponentColumn(address -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address)); Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openEditDialog(address));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
@@ -92,8 +95,11 @@ public class AddressBookView extends VerticalLayout {
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR, delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL); ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen"); delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete); HorizontalLayout actions = new HorizontalLayout(edit, delete);
}).setHeader("").setFlexGrow(0).setAutoWidth(true); actions.setSpacing(false);
actions.setPadding(false);
return actions;
}).setHeader("").setFlexGrow(0).setWidth("110px");
grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem())); grid.addItemDoubleClickListener(event -> openEditDialog(event.getItem()));
grid.setSizeFull(); grid.setSizeFull();
add(grid); add(grid);
@@ -122,7 +128,7 @@ public class AddressBookView extends VerticalLayout {
Address target = address == null ? new Address() : address; Address target = address == null ? new Address() : address;
Dialog dialog = new Dialog(); Dialog dialog = new Dialog();
dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten"); dialog.setHeaderTitle(isNew ? "Neues Unternehmen" : "Unternehmen bearbeiten");
ComboBox<AddressType> type = new ComboBox<>("Typ"); ComboBox<AddressType> type = new ComboBox<>("Typ");
type.setItems(AddressType.values()); type.setItems(AddressType.values());
@@ -141,7 +147,6 @@ public class AddressBookView extends VerticalLayout {
countryCode.setPattern("[A-Za-z]{2}"); countryCode.setPattern("[A-Za-z]{2}");
TextField vatId = new TextField("USt-IdNr."); TextField vatId = new TextField("USt-IdNr.");
vatId.setValue(target.getVatId() != null ? target.getVatId() : ""); vatId.setValue(target.getVatId() != null ? target.getVatId() : "");
vatId.setHelperText("z. B. DE261094748");
EmailField email = new EmailField("E-Mail"); EmailField email = new EmailField("E-Mail");
email.setValue(target.getEmail() != null ? target.getEmail() : ""); email.setValue(target.getEmail() != null ? target.getEmail() : "");
// E-Mail direkt beim Verlassen des Feldes prüfen // E-Mail direkt beim Verlassen des Feldes prüfen
@@ -153,8 +158,19 @@ public class AddressBookView extends VerticalLayout {
}); });
TextField phone = new TextField("Telefon"); TextField phone = new TextField("Telefon");
phone.setValue(target.getPhone() != null ? target.getPhone() : ""); phone.setValue(target.getPhone() != null ? target.getPhone() : "");
TextField website = new TextField("Webseite");
website.setValue(target.getWebsite() != null ? target.getWebsite() : "");
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone); // Bankverbindung (optional)
TextField bankName = new TextField("Bank");
bankName.setValue(target.getBankName() != null ? target.getBankName() : "");
TextField iban = new TextField("IBAN");
iban.setValue(target.getIban() != null ? target.getIban() : "");
TextField bic = new TextField("BIC");
bic.setValue(target.getBic() != null ? target.getBic() : "");
FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone,
website, bankName, iban, bic);
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2)); form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2));
form.setWidth("560px"); form.setWidth("560px");
dialog.add(form); dialog.add(form);
@@ -189,6 +205,17 @@ public class AddressBookView extends VerticalLayout {
email.setInvalid(true); email.setInvalid(true);
errors.add("E-Mail-Adresse ist ungültig."); errors.add("E-Mail-Adresse ist ungültig.");
} }
if (!iban.getValue().isBlank() && !IbanValidator.isValid(iban.getValue())) {
iban.setErrorMessage("IBAN ist ungültig (Prüfsumme).");
iban.setInvalid(true);
errors.add("IBAN ist ungültig (Prüfsumme).");
}
if (!bic.getValue().isBlank()
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
bic.setErrorMessage("BIC ist ungültig (8 oder 11 Stellen).");
bic.setInvalid(true);
errors.add("BIC ist ungültig (8 oder 11 Stellen).");
}
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE) Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_ERROR); .addThemeVariants(NotificationVariant.LUMO_ERROR);
@@ -204,6 +231,10 @@ public class AddressBookView extends VerticalLayout {
target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue())); target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue()));
target.setEmail(email.getValue().trim()); target.setEmail(email.getValue().trim());
target.setPhone(phone.getValue().trim()); target.setPhone(phone.getValue().trim());
target.setWebsite(website.getValue().trim());
target.setBankName(bankName.getValue().trim());
target.setIban(iban.getValue().isBlank() ? "" : IbanValidator.normalize(iban.getValue()));
target.setBic(bic.getValue().trim().toUpperCase());
target.touch(); target.touch();
repository.save(target); repository.save(target);
@@ -13,6 +13,7 @@ import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Paragraph; import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
@@ -22,17 +23,15 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.BigDecimalField; import com.vaadin.flow.component.textfield.BigDecimalField;
import com.vaadin.flow.component.textfield.EmailField; import com.vaadin.flow.component.textfield.EmailField;
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.data.renderer.ComponentRenderer;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.Address;
import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressRepository;
import de.assecutor.pdftool.address.AddressType; import de.assecutor.pdftool.address.AddressType;
import de.assecutor.pdftool.invoice.CapturedInvoiceData; import de.assecutor.pdftool.invoice.CapturedInvoiceData;
import de.assecutor.pdftool.invoice.IbanValidator;
import de.assecutor.pdftool.invoice.InvoiceDraft; import de.assecutor.pdftool.invoice.InvoiceDraft;
import de.assecutor.pdftool.invoice.InvoiceDraftService; import de.assecutor.pdftool.invoice.InvoiceDraftService;
import de.assecutor.pdftool.invoice.InvoiceMetadata; import de.assecutor.pdftool.invoice.InvoiceMetadata;
@@ -41,13 +40,10 @@ import de.assecutor.pdftool.template.InvoiceTemplateService;
import de.assecutor.pdftool.template.TemplatePdfService; import de.assecutor.pdftool.template.TemplatePdfService;
import de.assecutor.pdftool.template.TemplateRenderException; import de.assecutor.pdftool.template.TemplateRenderException;
import de.assecutor.pdftool.template.TemplateVariables; import de.assecutor.pdftool.template.TemplateVariables;
import de.assecutor.pdftool.zugferd.ZugferdConversionException; import de.assecutor.pdftool.pdfservice.PdfServiceClient;
import de.assecutor.pdftool.zugferd.ZugferdResult; import de.assecutor.pdftool.pdfservice.PdfServiceException;
import de.assecutor.pdftool.zugferd.ZugferdService;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.LocalDate; import java.time.LocalDate;
@@ -66,15 +62,12 @@ public class MainView extends VerticalLayout {
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100"); private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
private final ZugferdService zugferdService; private final PdfServiceClient pdfServiceClient;
private final CapturedInvoiceData capturedInvoiceData; private final CapturedInvoiceData capturedInvoiceData;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final TemplatePdfService templatePdfService; private final TemplatePdfService templatePdfService;
private final MemoryBuffer uploadBuffer = new MemoryBuffer(); /** Grundlage der Rechnung: ein gespeichertes Rechnungstemplate. */
private byte[] uploadedPdf;
/** Alternative zum PDF-Upload: ein gespeichertes Rechnungstemplate. */
private final ComboBox<String> templateSelect = new ComboBox<>("Rechnungstemplate"); private final ComboBox<String> templateSelect = new ComboBox<>("Rechnungstemplate");
/** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */ /** Vorbelegung der Rechnungssteller-Felder aus dem Adressbuch. */
@@ -107,9 +100,6 @@ public class MainView extends VerticalLayout {
private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum"); private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum");
private final TextField currency = new TextField("Währung"); private final TextField currency = new TextField("Währung");
private final TextField paymentTerms = new TextField("Zahlungsbedingungen"); private final TextField paymentTerms = new TextField("Zahlungsbedingungen");
private final TextField buyerReference = new TextField("Käuferreferenz / Leitweg-ID");
private final TextField iban = new TextField("IBAN (Rechnungssteller)");
private final TextField bic = new TextField("BIC");
private final PartyForm sender = new PartyForm(true); private final PartyForm sender = new PartyForm(true);
private final PartyForm recipient = new PartyForm(false); private final PartyForm recipient = new PartyForm(false);
@@ -121,16 +111,15 @@ public class MainView extends VerticalLayout {
private final Anchor downloadLink = new Anchor(); private final Anchor downloadLink = new Anchor();
/** /**
* Alle Controls unterhalb der Quellenwahl; sie sind erst aktiv, wenn ein PDF * Alle Controls unterhalb der Template-Auswahl; sie sind erst aktiv, wenn
* hochgeladen oder ein Template ausgewählt wurde. * ein Template ausgewählt wurde.
*/ */
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>(); private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData, public MainView(PdfServiceClient pdfServiceClient, CapturedInvoiceData capturedInvoiceData,
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService, InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService, AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
Environment environment) { this.pdfServiceClient = pdfServiceClient;
this.zugferdService = zugferdService;
this.capturedInvoiceData = capturedInvoiceData; this.capturedInvoiceData = capturedInvoiceData;
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.templatePdfService = templatePdfService; this.templatePdfService = templatePdfService;
@@ -139,15 +128,33 @@ public class MainView extends VerticalLayout {
setMaxWidth("960px"); setMaxWidth("960px");
add(new Paragraph("Laden Sie ein Rechnungs-PDF hoch oder wählen Sie ein gespeichertes " add(new Paragraph("Wählen Sie ein gespeichertes Rechnungstemplate und ergänzen Sie die "
+ "Rechnungstemplate und ergänzen Sie die Rechnungsdaten. Die Rechnung wird als " + "Rechnungsdaten. Die Rechnung wird als rechtskonforme ZUGFeRD-Rechnung "
+ "rechtskonforme ZUGFeRD-Rechnung (PDF/A-3 mit eingebettetem EN16931-XML) erzeugt " + "(PDF/A-3 mit eingebettetem EN16931-XML) erzeugt und als ZIP-Datei bereitgestellt."));
+ "und als ZIP-Datei bereitgestellt."));
// Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden. // Eingaben unter einem Namen speichern bzw. gespeicherte Eingaben laden.
draftSelect.setPlaceholder("Vorbelegung laden"); draftSelect.setPlaceholder("Vorbelegung laden");
draftSelect.setClearButtonVisible(true); draftSelect.setClearButtonVisible(true);
draftSelect.setWidth("300px"); draftSelect.setWidth("300px");
// Jeder Eintrag zeigt rechts ein Löschen-Icon, das die Vorbelegung
// nach einer Sicherheitsabfrage aus MongoDB entfernt.
draftSelect.setRenderer(new ComponentRenderer<>(name -> {
Span label = new Span(name);
Icon delete = VaadinIcon.TRASH.create();
delete.setSize("var(--lumo-icon-size-s)");
delete.getStyle().set("color", "var(--lumo-error-color)").set("cursor", "pointer");
delete.getElement().setAttribute("title", "Vorbelegung löschen");
// Der Klick auf das Icon darf den Eintrag nicht auswählen; der
// Server-Listener auf dem Icon selbst wird davon nicht berührt.
delete.getElement().executeJs("this.addEventListener('click', function(e) { e.stopPropagation(); })");
delete.getElement().addEventListener("click", e -> confirmDeleteDraft(name));
HorizontalLayout row = new HorizontalLayout(label, delete);
row.setJustifyContentMode(JustifyContentMode.BETWEEN);
row.setAlignItems(Alignment.CENTER);
row.setWidthFull();
row.setPadding(false);
return row;
}));
draftSelect.addValueChangeListener(event -> { draftSelect.addValueChangeListener(event -> {
if (event.isFromClient() && event.getValue() != null) { if (event.isFromClient() && event.getValue() != null) {
loadDraft(event.getValue()); loadDraft(event.getValue());
@@ -158,46 +165,24 @@ public class MainView extends VerticalLayout {
}); });
add(draftSelect); add(draftSelect);
Upload upload = new Upload(uploadBuffer);
upload.setAcceptedFileTypes("application/pdf", ".pdf");
upload.setMaxFiles(1);
upload.setMaxFileSize(25 * 1024 * 1024);
upload.addSucceededListener(event -> {
try {
uploadedPdf = uploadBuffer.getInputStream().readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
// Entweder PDF oder Template: hochgeladenes PDF hebt die Template-Auswahl auf.
templateSelect.clear();
updateControlsEnabled();
});
upload.addFileRemovedListener(event -> {
uploadedPdf = null;
updateControlsEnabled();
});
templateSelect.setItems(invoiceTemplateService.templateNames()); templateSelect.setItems(invoiceTemplateService.templateNames());
templateSelect.setPlaceholder("Template auswählen"); templateSelect.setPlaceholder("Template auswählen");
templateSelect.setClearButtonVisible(true); templateSelect.setClearButtonVisible(true);
templateSelect.setWidth("300px"); templateSelect.setWidth("300px");
templateSelect.addValueChangeListener(event -> { templateSelect.addValueChangeListener(event -> {
if (event.isFromClient() && event.getValue() != null) { if (event.isFromClient() && event.getValue() != null) {
// Entweder PDF oder Template: Auswahl entfernt ein hochgeladenes PDF. // Rechnungssteller-Felder nur bei der ersten Template-Auswahl
upload.clearFileList(); // leer beginnen lassen; beim Wechsel zwischen Templates bleiben
uploadedPdf = null; // die Eingaben erhalten (sonst wäre u. a. der Speichern-Button
// Rechnungssteller-Felder leer beginnen lassen. // wegen leerer Pflichtfelder nie aktiv).
senderAddressSelect.clear(); if (event.getOldValue() == null) {
sender.clearFields(); senderAddressSelect.clear();
sender.clearFields();
}
} }
updateControlsEnabled(); updateControlsEnabled();
}); });
HorizontalLayout sourceLayout = new HorizontalLayout(upload, templateSelect); add(new H3("1. Template auswählen"), templateSelect);
sourceLayout.setAlignItems(Alignment.CENTER);
// Das Label der ComboBox ausgleichen, damit das Eingabefeld selbst
// mittig zur Upload-Fläche steht.
templateSelect.getStyle().set("margin-top", "-1.25em");
add(new H3("1. Rechnungs-PDF hochladen oder Template auswählen"), sourceLayout);
invoiceNumber.setRequired(true); invoiceNumber.setRequired(true);
invoiceNumber.setErrorMessage("Rechnungsnummer ist ein Pflichtfeld."); invoiceNumber.setErrorMessage("Rechnungsnummer ist ein Pflichtfeld.");
@@ -209,10 +194,8 @@ public class MainView extends VerticalLayout {
currency.setPattern("[A-Za-z]{3}"); currency.setPattern("[A-Za-z]{3}");
currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR."); currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR.");
currency.setValue("EUR"); currency.setValue("EUR");
buyerReference.setHelperText("BT-10 — für Rechnungen an Behörden erforderlich");
iban.setHelperText("Für die Zahlungsangaben (BG-16) empfohlen");
FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency, FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency,
paymentTerms, buyerReference, iban, bic); paymentTerms);
invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3)); invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3));
add(new H3("2. Rechnungsdaten"), invoiceForm); add(new H3("2. Rechnungsdaten"), invoiceForm);
@@ -233,17 +216,16 @@ public class MainView extends VerticalLayout {
recipientAddressSelect, recipient, itemsGrid, addItem, generate)); recipientAddressSelect, recipient, itemsGrid, addItem, generate));
updateControlsEnabled(); updateControlsEnabled();
// Unsichtbarer Anchor: dient nur als Träger für den automatischen
// Download nach dem Erzeugen (display:none statt setVisible, damit
// callJsFunction("click") den Klick sofort ausführen kann).
downloadLink.getElement().setAttribute("download", true); downloadLink.getElement().setAttribute("download", true);
downloadLink.setVisible(false); downloadLink.getStyle().set("display", "none");
add(new HorizontalLayout(generate, saveDraftButton, downloadLink)); add(new HorizontalLayout(generate, saveDraftButton, downloadLink));
registerDraftDirtyTracking(); registerDraftDirtyTracking();
updateSaveDraftEnabled(); updateSaveDraftEnabled();
if (environment.acceptsProfiles(Profiles.of("dev"))) {
prefillDevData();
}
} }
@Override @Override
@@ -301,6 +283,39 @@ public class MainView extends VerticalLayout {
confirm.open(); confirm.open();
} }
/** Sicherheitsabfrage vor dem endgültigen Löschen einer Vorbelegung. */
private void confirmDeleteDraft(String name) {
ConfirmDialog confirm = new ConfirmDialog();
confirm.setHeader("Vorbelegung löschen?");
confirm.setText("Sollen die gespeicherten Eingaben \"" + name + "\" endgültig gelöscht werden?");
confirm.setConfirmText("Löschen");
confirm.setConfirmButtonTheme("error primary");
confirm.setCancelable(true);
confirm.setCancelText("Abbrechen");
confirm.addConfirmListener(event -> deleteDraft(name));
confirm.open();
}
private void deleteDraft(String name) {
try {
invoiceDraftService.delete(name);
} catch (Exception ex) {
showErrors(List.of("Die Vorbelegung \"" + name + "\" konnte nicht gelöscht werden: " + ex.getMessage()));
return;
}
boolean wasSelected = name.equals(draftSelect.getValue());
draftSelect.setItems(invoiceDraftService.names());
if (wasSelected) {
// Die geladene Vorbelegung existiert nicht mehr; das Formular
// behält seine Werte, gilt aber wieder als ungespeichert.
draftSelect.clear();
loadedDraftState = null;
updateSaveDraftEnabled();
}
Notification.show("Vorbelegung \"" + name + "\" gelöscht.", 3000, Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
private void saveDraft(String name) { private void saveDraft(String name) {
try { try {
invoiceDraftService.save(buildDraft(name)); invoiceDraftService.save(buildDraft(name));
@@ -317,7 +332,7 @@ public class MainView extends VerticalLayout {
.addThemeVariants(NotificationVariant.LUMO_SUCCESS); .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} }
/** Sammelt den aktuellen Formularstand; ein hochgeladenes PDF wird nicht mitgespeichert. */ /** Sammelt den aktuellen Formularstand. */
private InvoiceDraft buildDraft(String name) { private InvoiceDraft buildDraft(String name) {
return new InvoiceDraft(null, name, return new InvoiceDraft(null, name,
templateSelect.getValue(), templateSelect.getValue(),
@@ -325,9 +340,10 @@ public class MainView extends VerticalLayout {
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
currency.getValue().trim(), currency.getValue().trim(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().trim(), sender.ibanNormalized(),
bic.getValue().trim(), sender.bicNormalized(),
sender.websiteValue(),
sender.toParty(), recipient.toParty(), sender.toParty(), recipient.toParty(),
List.copyOf(lineItems), List.copyOf(lineItems),
LocalDateTime.now()); LocalDateTime.now());
@@ -346,8 +362,7 @@ public class MainView extends VerticalLayout {
/** Überträgt gespeicherte Eingaben in das Formular. */ /** Überträgt gespeicherte Eingaben in das Formular. */
private void applyDraft(InvoiceDraft draft) { private void applyDraft(InvoiceDraft draft) {
// Die Template-Auswahl wiederherstellen, sofern das Template noch existiert; // Die Template-Auswahl wiederherstellen, sofern das Template noch existiert.
// ein hochgeladenes PDF ist nicht Teil der gespeicherten Eingaben.
if (draft.templateName() != null && !draft.templateName().isBlank() if (draft.templateName() != null && !draft.templateName().isBlank()
&& invoiceTemplateService.templateExists(draft.templateName())) { && invoiceTemplateService.templateExists(draft.templateName())) {
templateSelect.setValue(draft.templateName()); templateSelect.setValue(draft.templateName());
@@ -358,10 +373,10 @@ public class MainView extends VerticalLayout {
dueDate.setValue(draft.dueDate()); dueDate.setValue(draft.dueDate());
currency.setValue(nullToEmpty(draft.currency())); currency.setValue(nullToEmpty(draft.currency()));
paymentTerms.setValue(nullToEmpty(draft.paymentTerms())); paymentTerms.setValue(nullToEmpty(draft.paymentTerms()));
buyerReference.setValue(nullToEmpty(draft.buyerReference()));
iban.setValue(nullToEmpty(draft.iban()));
bic.setValue(nullToEmpty(draft.bic()));
applyParty(sender, draft.sender()); applyParty(sender, draft.sender());
sender.prefillContact(draft.sender() != null ? nullToEmpty(draft.sender().phone()) : "",
nullToEmpty(draft.website()));
sender.prefillBank(nullToEmpty(draft.iban()), nullToEmpty(draft.bic()));
applyParty(recipient, draft.recipient()); applyParty(recipient, draft.recipient());
lineItems.clear(); lineItems.clear();
if (draft.items() != null) { if (draft.items() != null) {
@@ -382,7 +397,7 @@ public class MainView extends VerticalLayout {
form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()), form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()),
nullToEmpty(party.zip()), nullToEmpty(party.city()), nullToEmpty(party.zip()), nullToEmpty(party.city()),
nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()), nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()),
nullToEmpty(party.email()), nullToEmpty(party.phone())); nullToEmpty(party.email()));
} }
/** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */ /** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */
@@ -397,7 +412,9 @@ public class MainView extends VerticalLayout {
form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()), form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()),
nullToEmpty(address.getZip()), nullToEmpty(address.getCity()), nullToEmpty(address.getZip()), nullToEmpty(address.getCity()),
nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()), nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()),
nullToEmpty(address.getEmail()), nullToEmpty(address.getPhone())); nullToEmpty(address.getEmail()));
form.prefillContact(nullToEmpty(address.getPhone()), nullToEmpty(address.getWebsite()));
form.prefillBank(nullToEmpty(address.getIban()), nullToEmpty(address.getBic()));
} }
}); });
} }
@@ -415,22 +432,6 @@ public class MainView extends VerticalLayout {
return List.copyOf(lineItems); return List.copyOf(lineItems);
} }
/** Belegt das Formular im dev-Profil mit Testdaten vor, um manuelles Tippen zu sparen. */
private void prefillDevData() {
invoiceNumber.setValue("11111");
deliveryDate.setValue(LocalDate.now());
dueDate.setValue(LocalDate.now().plusDays(14));
paymentTerms.setValue("14 Tage");
buyerReference.setValue("11111");
iban.setValue("DE64 6001 0070 0379 0907 00");
bic.setValue("PBNKDEFFXXX");
// Rechnungssteller und -empfänger bleiben leer und werden über die
// Adressbuch-Auswahl befüllt.
lineItems.add(new InvoiceMetadata.LineItem("sdf", BigDecimal.ONE, new BigDecimal("100"),
new BigDecimal("19")));
refreshItemsGrid();
}
/** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */ /** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */
private void configureItemsGrid() { private void configureItemsGrid() {
itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1); itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1);
@@ -440,6 +441,8 @@ public class MainView extends VerticalLayout {
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0); .setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %") itemsGrid.addColumn(item -> formatDecimal(item.vatPercent())).setHeader("USt %")
.setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0); .setTextAlign(ColumnTextAlign.END).setAutoWidth(true).setFlexGrow(0);
// Feste Breite statt AutoWidth: Vaadin misst Komponentenspalten vor dem
// Rendern der Buttons, dadurch würden die Icons rechts abgeschnitten.
itemsGrid.addComponentColumn(item -> { itemsGrid.addComponentColumn(item -> {
Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item)); Button edit = new Button(new Icon(VaadinIcon.EDIT), event -> openItemDialog(item));
edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
@@ -451,8 +454,11 @@ public class MainView extends VerticalLayout {
delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR, delete.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR,
ButtonVariant.LUMO_SMALL); ButtonVariant.LUMO_SMALL);
delete.setTooltipText("Löschen"); delete.setTooltipText("Löschen");
return new HorizontalLayout(edit, delete); HorizontalLayout actions = new HorizontalLayout(edit, delete);
}).setHeader("").setAutoWidth(true).setFlexGrow(0); actions.setSpacing(false);
actions.setPadding(false);
return actions;
}).setHeader("").setWidth("110px").setFlexGrow(0);
itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem())); itemsGrid.addItemDoubleClickListener(event -> openItemDialog(event.getItem()));
itemsGrid.setAllRowsVisible(true); itemsGrid.setAllRowsVisible(true);
itemsGrid.setWidthFull(); itemsGrid.setWidthFull();
@@ -471,20 +477,28 @@ public class MainView extends VerticalLayout {
dueDate.addValueChangeListener(event -> updateSaveDraftEnabled()); dueDate.addValueChangeListener(event -> updateSaveDraftEnabled());
currency.addValueChangeListener(event -> updateSaveDraftEnabled()); currency.addValueChangeListener(event -> updateSaveDraftEnabled());
paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled()); paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled());
buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled());
iban.addValueChangeListener(event -> updateSaveDraftEnabled());
bic.addValueChangeListener(event -> updateSaveDraftEnabled());
templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled()); templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled());
sender.onAnyChange(this::updateSaveDraftEnabled); sender.onAnyChange(this::updateSaveDraftEnabled);
recipient.onAnyChange(this::updateSaveDraftEnabled); recipient.onAnyChange(this::updateSaveDraftEnabled);
} }
/** /**
* Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde * Der Speichern-Button ist nur aktiv, wenn alle Pflichtfelder gefüllt sind
* oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben. * und keine Vorbelegung geladen wurde oder sich die Eingaben seit dem Laden
* bzw. Speichern geändert haben.
*/ */
private void updateSaveDraftEnabled() { private void updateSaveDraftEnabled() {
saveDraftButton.setEnabled(loadedDraftState == null || !comparableState().equals(loadedDraftState)); boolean changed = loadedDraftState == null || !comparableState().equals(loadedDraftState);
saveDraftButton.setEnabled(changed && requiredFieldsFilled());
}
/** @return true, wenn alle Pflichtfelder der Maske gefüllt sind. */
private boolean requiredFieldsFilled() {
return !invoiceNumber.getValue().isBlank()
&& issueDate.getValue() != null
&& !currency.getValue().isBlank()
&& sender.requiredFieldsFilled()
&& recipient.requiredFieldsFilled();
} }
/** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */ /** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */
@@ -495,9 +509,10 @@ public class MainView extends VerticalLayout {
issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(),
currency.getValue().trim(), currency.getValue().trim(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().trim(), sender.ibanNormalized(),
bic.getValue().trim(), sender.bicNormalized(),
sender.websiteValue(),
sender.toParty(), recipient.toParty(), sender.toParty(), recipient.toParty(),
List.copyOf(lineItems), List.copyOf(lineItems),
null); null);
@@ -573,24 +588,19 @@ public class MainView extends VerticalLayout {
/** /**
* Das Rechnungsformular und der Erzeugen-Button sind nur aktiv, wenn ein * Das Rechnungsformular und der Erzeugen-Button sind nur aktiv, wenn ein
* PDF hochgeladen oder ein Template ausgewählt wurde. * Template ausgewählt wurde.
*/ */
private void updateControlsEnabled() { private void updateControlsEnabled() {
boolean sourceSelected = uploadedPdf != null || templateSelect.getValue() != null; boolean sourceSelected = templateSelect.getValue() != null;
sourceDependentControls.forEach(control -> control.setEnabled(sourceSelected)); sourceDependentControls.forEach(control -> control.setEnabled(sourceSelected));
} }
private void generate() { private void generate() {
downloadLink.setVisible(false);
List<String> errors = validateForm(); List<String> errors = validateForm();
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
showErrors(errors); showErrors(errors);
return; return;
} }
if (uploadedPdf != null) {
convertAndOfferDownload(uploadedPdf);
return;
}
String templateName = templateSelect.getValue(); String templateName = templateSelect.getValue();
Optional<String> templateData = invoiceTemplateService.loadTemplate(templateName); Optional<String> templateData = invoiceTemplateService.loadTemplate(templateName);
if (templateData.isEmpty()) { if (templateData.isEmpty()) {
@@ -615,12 +625,20 @@ public class MainView extends VerticalLayout {
private Map<String, String> buildTemplateVariables(InvoiceMetadata metadata) { private Map<String, String> buildTemplateVariables(InvoiceMetadata metadata) {
Map<String, String> variables = new LinkedHashMap<>(); Map<String, String> variables = new LinkedHashMap<>();
InvoiceMetadata.Party senderParty = metadata.sender(); InvoiceMetadata.Party senderParty = metadata.sender();
// Telefon und Webseite kommen aus den Formularfeldern des
// Rechnungsstellers; sind sie leer (z. B. alte Vorbelegung), springt
// der Adressbuch-Eintrag des Rechnungsstellers ein.
Optional<Address> senderAddress = findSenderAddress(senderParty.name());
String phone = !senderParty.phone().isBlank() ? senderParty.phone()
: senderAddress.map(address -> nullToEmpty(address.getPhone())).orElse("");
String website = !sender.websiteValue().isBlank() ? sender.websiteValue()
: senderAddress.map(address -> nullToEmpty(address.getWebsite())).orElse("");
variables.put("masterdata.company_name", senderParty.name()); variables.put("masterdata.company_name", senderParty.name());
variables.put("masterdata.street", senderParty.street()); variables.put("masterdata.street", senderParty.street());
variables.put("masterdata.city", senderParty.zip() + " " + senderParty.city()); variables.put("masterdata.city", senderParty.zip() + " " + senderParty.city());
variables.put("masterdata.phone", senderParty.phone()); variables.put("masterdata.phone", phone);
variables.put("masterdata.email", senderParty.email()); variables.put("masterdata.email", senderParty.email());
variables.put("masterdata.website", ""); variables.put("masterdata.website", website);
variables.put("masterdata.sender_line", variables.put("masterdata.sender_line",
senderParty.name() + " · " + senderParty.street() + " · " + senderParty.zip() + " " + senderParty.city()); senderParty.name() + " · " + senderParty.street() + " · " + senderParty.zip() + " " + senderParty.city());
variables.put("masterdata.payment_terms", metadata.paymentTerms()); variables.put("masterdata.payment_terms", metadata.paymentTerms());
@@ -651,31 +669,46 @@ public class MainView extends VerticalLayout {
return variables; return variables;
} }
/** Wandelt das Quell-PDF in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */ /**
* @return der Adressbuch-Eintrag des Rechnungsstellers: die Auswahl in
* "Aus dem Adressbuch übernehmen", sonst der Namenstreffer unter
* den Rechnungssteller-Adressen (z. B. nach Laden einer Vorbelegung).
*/
private Optional<Address> findSenderAddress(String senderName) {
Address selected = senderAddressSelect.getValue();
if (selected != null) {
return Optional.of(selected);
}
String normalized = senderName == null ? "" : senderName.trim();
return addressRepository.findByType(AddressType.RECHNUNGSSTELLER).stream()
.filter(address -> normalized.equalsIgnoreCase(nullToEmpty(address.getName()).trim()))
.findFirst();
}
/** Wandelt das Quell-PDF über den PDF Service in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */
private void convertAndOfferDownload(byte[] sourcePdf) { private void convertAndOfferDownload(byte[] sourcePdf) {
try { try {
InvoiceMetadata metadata = collectMetadata(); InvoiceMetadata metadata = collectMetadata();
capturedInvoiceData.setItems(metadata.items()); capturedInvoiceData.setItems(metadata.items());
ZugferdResult result = zugferdService.createZugferdZip(sourcePdf, metadata); PdfServiceClient.SignResult result = pdfServiceClient.sign(sourcePdf, metadata);
StreamResource resource = new StreamResource(result.zipFileName(), StreamResource resource = new StreamResource(result.zipFileName(),
() -> new ByteArrayInputStream(result.zip())); () -> new ByteArrayInputStream(result.zip()));
resource.setContentType("application/zip"); resource.setContentType("application/zip");
downloadLink.setHref(resource); downloadLink.setHref(resource);
downloadLink.setText("ZIP herunterladen (" + result.zipFileName() + ")");
downloadLink.setVisible(true);
if (result.valid()) { if (result.valid()) {
Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000, Notification.show("E-Rechnung erzeugt und erfolgreich validiert.", 4000,
Notification.Position.BOTTOM_START) Notification.Position.BOTTOM_START)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS); .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
downloadLink.getElement().callJsFunction("click");
} else { } else {
Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. " Notification.show("E-Rechnung erzeugt, aber die Validierung meldet Probleme. "
+ "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000, + "Details stehen im Prüfbericht (validation-report.xml) im ZIP.", 8000,
Notification.Position.MIDDLE) Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_WARNING); .addThemeVariants(NotificationVariant.LUMO_WARNING);
} }
} catch (ZugferdConversionException e) { } catch (PdfServiceException e) {
showErrors(List.of(e.getMessage())); showErrors(List.of(e.getMessage()));
} }
} }
@@ -694,16 +727,6 @@ public class MainView extends VerticalLayout {
&& !currency.getValue().trim().matches("[A-Za-z]{3}")) { && !currency.getValue().trim().matches("[A-Za-z]{3}")) {
markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors); markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors);
} }
if (!iban.isEmpty() && !isValidIban(iban.getValue())) {
markInvalid(iban, "IBAN ist ungültig (Prüfsumme).", errors);
} else {
iban.setInvalid(false);
}
if (!bic.isEmpty() && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
markInvalid(bic, "BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
} else {
bic.setInvalid(false);
}
errors.addAll(sender.validateFields("Rechnungssteller")); errors.addAll(sender.validateFields("Rechnungssteller"));
errors.addAll(recipient.validateFields("Rechnungsempfänger")); errors.addAll(recipient.validateFields("Rechnungsempfänger"));
if (lineItems.isEmpty()) { if (lineItems.isEmpty()) {
@@ -720,9 +743,9 @@ public class MainView extends VerticalLayout {
dueDate.getValue(), dueDate.getValue(),
currency.getValue().trim().toUpperCase(), currency.getValue().trim().toUpperCase(),
paymentTerms.getValue(), paymentTerms.getValue(),
buyerReference.getValue().trim(), "",
iban.getValue().replaceAll("\\s", "").toUpperCase(), sender.ibanNormalized(),
bic.getValue().trim().toUpperCase(), sender.bicNormalized(),
sender.toParty(), sender.toParty(),
recipient.toParty(), recipient.toParty(),
lineItems lineItems
@@ -760,21 +783,6 @@ public class MainView extends VerticalLayout {
errors.add(message); errors.add(message);
} }
/** IBAN-Prüfung nach ISO 13616 (MOD 97-10). */
private static boolean isValidIban(String value) {
String normalized = value.replaceAll("\\s", "").toUpperCase();
if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) {
return false;
}
String rearranged = normalized.substring(4) + normalized.substring(0, 4);
int mod = 0;
for (char c : rearranged.toCharArray()) {
int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10;
mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97;
}
return mod == 1;
}
private void showErrors(List<String> messages) { private void showErrors(List<String> messages) {
Notification notification = new Notification(); Notification notification = new Notification();
notification.addThemeVariants(NotificationVariant.LUMO_ERROR); notification.addThemeVariants(NotificationVariant.LUMO_ERROR);
@@ -802,7 +810,14 @@ public class MainView extends VerticalLayout {
private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)"); private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)");
private final TextField vatId = new TextField("USt-IdNr."); private final TextField vatId = new TextField("USt-IdNr.");
private final EmailField email = new EmailField("E-Mail"); private final EmailField email = new EmailField("E-Mail");
// Kontaktangaben; nur beim Rechnungssteller sichtbar und optional
private final TextField phone = new TextField("Telefon"); private final TextField phone = new TextField("Telefon");
private final TextField website = new TextField("Webseite");
// Bankverbindung; nur beim Rechnungssteller sichtbar und Pflicht
private final TextField iban = new TextField("IBAN");
private final TextField bic = new TextField("BIC");
PartyForm(boolean vatRequired) { PartyForm(boolean vatRequired) {
this.vatRequired = vatRequired; this.vatRequired = vatRequired;
@@ -816,7 +831,6 @@ public class MainView extends VerticalLayout {
countryCode.setPattern("[A-Za-z]{2}"); countryCode.setPattern("[A-Za-z]{2}");
countryCode.setErrorMessage("Zweistelliger ISO-Code, z. B. DE."); countryCode.setErrorMessage("Zweistelliger ISO-Code, z. B. DE.");
vatId.setRequired(vatRequired); vatId.setRequired(vatRequired);
vatId.setHelperText("z. B. DE261094748");
// USt-IdNr. direkt beim Verlassen des Feldes prüfen // USt-IdNr. direkt beim Verlassen des Feldes prüfen
vatId.addValueChangeListener(event -> checkVatId()); vatId.addValueChangeListener(event -> checkVatId());
// Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht // Elektronische Adresse (BT-34/BT-49) ist nach PEPPOL-EN16931 Pflicht
@@ -824,15 +838,43 @@ public class MainView extends VerticalLayout {
email.setErrorMessage("Gültige E-Mail-Adresse angeben."); email.setErrorMessage("Gültige E-Mail-Adresse angeben.");
// E-Mail direkt beim Verlassen des Feldes prüfen // E-Mail direkt beim Verlassen des Feldes prüfen
email.addValueChangeListener(event -> checkEmail()); email.addValueChangeListener(event -> checkEmail());
add(name, street, zip, city, countryCode, vatId, email);
if (vatRequired) { if (vatRequired) {
phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen"); iban.setRequired(true);
bic.setRequired(true);
add(phone, website, iban, bic);
} }
add(name, street, zip, city, countryCode, vatId, email, phone);
setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3)); setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3));
} }
/** Belegt die Bankverbindung vor, z. B. aus dem Adressbuch. */
void prefillBank(String ibanValue, String bicValue) {
iban.setValue(ibanValue);
bic.setValue(bicValue);
}
/** Belegt Telefon und Webseite vor, z. B. aus dem Adressbuch. */
void prefillContact(String phoneValue, String websiteValue) {
phone.setValue(phoneValue);
website.setValue(websiteValue);
}
String websiteValue() {
return website.getValue().trim();
}
/** IBAN ohne Leerzeichen in Großschreibung. */
String ibanNormalized() {
return iban.getValue().replaceAll("\\s", "").toUpperCase();
}
/** BIC ohne führende/abschließende Leerzeichen in Großschreibung. */
String bicNormalized() {
return bic.getValue().trim().toUpperCase();
}
void prefill(String nameValue, String streetValue, String zipValue, String cityValue, void prefill(String nameValue, String streetValue, String zipValue, String cityValue,
String countryValue, String vatIdValue, String emailValue, String phoneValue) { String countryValue, String vatIdValue, String emailValue) {
name.setValue(nameValue); name.setValue(nameValue);
street.setValue(streetValue); street.setValue(streetValue);
zip.setValue(zipValue); zip.setValue(zipValue);
@@ -840,7 +882,18 @@ public class MainView extends VerticalLayout {
countryCode.setValue(countryValue); countryCode.setValue(countryValue);
vatId.setValue(vatIdValue); vatId.setValue(vatIdValue);
email.setValue(emailValue); email.setValue(emailValue);
phone.setValue(phoneValue); }
/** @return true, wenn alle Pflichtfelder des Adressformulars gefüllt sind. */
boolean requiredFieldsFilled() {
return !name.getValue().isBlank()
&& !street.getValue().isBlank()
&& !zip.getValue().isBlank()
&& !city.getValue().isBlank()
&& !countryCode.getValue().isBlank()
&& (!vatRequired || !vatId.getValue().isBlank())
&& !email.getValue().isBlank()
&& (!vatRequired || (!iban.getValue().isBlank() && !bic.getValue().isBlank()));
} }
/** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */ /** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */
@@ -853,11 +906,16 @@ public class MainView extends VerticalLayout {
vatId.addValueChangeListener(event -> listener.run()); vatId.addValueChangeListener(event -> listener.run());
email.addValueChangeListener(event -> listener.run()); email.addValueChangeListener(event -> listener.run());
phone.addValueChangeListener(event -> listener.run()); phone.addValueChangeListener(event -> listener.run());
website.addValueChangeListener(event -> listener.run());
iban.addValueChangeListener(event -> listener.run());
bic.addValueChangeListener(event -> listener.run());
} }
/** Leert alle Felder; das Land behält den Standardwert "DE". */ /** Leert alle Felder; das Land behält den Standardwert "DE". */
void clearFields() { void clearFields() {
prefill("", "", "", "", "DE", "", "", ""); prefill("", "", "", "", "DE", "", "");
prefillContact("", "");
prefillBank("", "");
name.setInvalid(false); name.setInvalid(false);
street.setInvalid(false); street.setInvalid(false);
zip.setInvalid(false); zip.setInvalid(false);
@@ -865,6 +923,8 @@ public class MainView extends VerticalLayout {
countryCode.setInvalid(false); countryCode.setInvalid(false);
vatId.setInvalid(false); vatId.setInvalid(false);
email.setInvalid(false); email.setInvalid(false);
iban.setInvalid(false);
bic.setInvalid(false);
} }
/** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */ /** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */
@@ -915,6 +975,16 @@ public class MainView extends VerticalLayout {
} else if (!checkEmail()) { } else if (!checkEmail()) {
errors.add(partyLabel + ": E-Mail-Adresse ist ungültig."); errors.add(partyLabel + ": E-Mail-Adresse ist ungültig.");
} }
if (vatRequired) {
if (requireFilled(iban, partyLabel + ": IBAN ist ein Pflichtfeld.", errors)
&& !IbanValidator.isValid(iban.getValue())) {
markInvalid(iban, partyLabel + ": IBAN ist ungültig (Prüfsumme).", errors);
}
if (requireFilled(bic, partyLabel + ": BIC ist ein Pflichtfeld.", errors)
&& !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) {
markInvalid(bic, partyLabel + ": BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors);
}
}
return errors; return errors;
} }
@@ -0,0 +1,37 @@
package de.assecutor.pdftool.ui;
import com.vaadin.flow.server.CustomizedSystemMessages;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import org.springframework.stereotype.Component;
/**
* Verhindert das stille Neuladen der Seite durch Vaadin: Standardmäßig lädt
* Vaadin bei abgelaufener Session (z. B. nach einem Neustart der Anwendung)
* oder internen Fehlern die Seite sofort und ohne Hinweis neu mitten in der
* Arbeit im Rechnungsgenerator. Stattdessen wird jetzt eine Meldung angezeigt;
* die Seite lädt erst neu, wenn der Benutzer die Meldung bestätigt.
*/
@Component
public class SystemMessagesConfiguration implements VaadinServiceInitListener {
@Override
public void serviceInit(ServiceInitEvent event) {
event.getSource().setSystemMessagesProvider(info -> {
CustomizedSystemMessages messages = new CustomizedSystemMessages();
messages.setSessionExpiredNotificationEnabled(true);
messages.setSessionExpiredCaption("Sitzung abgelaufen");
messages.setSessionExpiredMessage("Die Sitzung ist abgelaufen, z. B. durch einen Neustart der "
+ "Anwendung. Zum Fortfahren klicken — nicht gespeicherte Änderungen im "
+ "Rechnungsgenerator werden wiederhergestellt.");
messages.setInternalErrorNotificationEnabled(true);
messages.setInternalErrorCaption("Interner Fehler");
messages.setInternalErrorMessage("Es ist ein interner Fehler aufgetreten. "
+ "Zum Fortfahren klicken; die Seite wird danach neu geladen.");
return messages;
});
}
}
@@ -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());
@@ -1,12 +0,0 @@
package de.assecutor.pdftool.zugferd;
public class ZugferdConversionException extends RuntimeException {
public ZugferdConversionException(String message) {
super(message);
}
public ZugferdConversionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -1,8 +0,0 @@
package de.assecutor.pdftool.zugferd;
/**
* Ergebnis einer ZUGFeRD-Konvertierung: die ZIP-Datei (ZUGFeRD-PDF, Factur-X-XML,
* Prüfbericht) sowie das Ergebnis der Mustang-Validierung.
*/
public record ZugferdResult(byte[] zip, String zipFileName, boolean valid, String validationReport) {
}
@@ -1,242 +0,0 @@
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.PDResources;
import org.mustangproject.BankDetails;
import org.mustangproject.Contact;
import org.mustangproject.Invoice;
import org.mustangproject.Item;
import org.mustangproject.Product;
import org.mustangproject.SchemedID;
import org.mustangproject.TradeParty;
import org.mustangproject.ZUGFeRD.IZUGFeRDExporter;
import org.mustangproject.ZUGFeRD.Profiles;
import org.mustangproject.ZUGFeRD.ZUGFeRD2PullProvider;
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1;
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromPDFA;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Wandelt ein herkömmliches Rechnungs-PDF in eine rechtskonforme ZUGFeRD-Rechnung
* (PDF/A-3 mit eingebettetem EN16931-XML, Profil EN 16931) um, validiert das
* Ergebnis mit dem Mustang-Validator und verpackt PDF, Factur-X-XML und
* Prüfbericht in eine ZIP-Datei.
*/
@Service
public class ZugferdService {
private static final Logger log = LoggerFactory.getLogger(ZugferdService.class);
private static final String PROFILE = "EN16931";
private static final String PRODUCER = "Assecutor Data Service GmbH Invoice Tool";
/** Business Process (BT-23), von PEPPOL-EN16931-R001 gefordert. */
private static final String BUSINESS_PROCESS = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0";
/** EAS-Schema "EM" = E-Mail-Adresse für die elektronische Adresse (BT-34/BT-49). */
private static final String EAS_EMAIL = "EM";
private final ZugferdValidationService validationService;
public ZugferdService(ZugferdValidationService validationService) {
this.validationService = validationService;
}
public ZugferdResult createZugferdZip(byte[] sourcePdf, InvoiceMetadata metadata) {
validatePdf(sourcePdf);
sourcePdf = ensurePageResources(sourcePdf);
Invoice invoice = buildInvoice(metadata);
byte[] zugferdPdf = embedXmlIntoPdf(sourcePdf, invoice);
byte[] facturXml = generateXml(invoice);
String baseName = sanitizeFileName(metadata.invoiceNumber());
String pdfName = baseName + "-zugferd.pdf";
ZugferdValidationService.ValidationResult validation = validationService.validate(zugferdPdf, pdfName);
byte[] zip = zip(
pdfName, zugferdPdf,
"factur-x.xml", facturXml,
"validation-report.xml", validation.reportXml().getBytes(StandardCharsets.UTF_8));
return new ZugferdResult(zip, baseName + ".zip", validation.valid(), validation.reportXml());
}
private void validatePdf(byte[] pdf) {
if (pdf == null || pdf.length < 5
|| !"%PDF-".equals(new String(pdf, 0, 5, StandardCharsets.US_ASCII))) {
throw new ZugferdConversionException("Die hochgeladene Datei ist kein gültiges PDF.");
}
}
/**
* Mustang setzt bei jeder Seite ein Resources-Dictionary voraus und stürzt
* sonst mit einer NullPointerException ab. Seiten ohne Resources (z.B. aus
* manchen PDF-Generatoren) erhalten deshalb vorab ein leeres Dictionary.
*/
private byte[] ensurePageResources(byte[] pdf) {
try (PDDocument doc = Loader.loadPDF(pdf)) {
boolean changed = false;
for (PDPage page : doc.getPages()) {
if (page.getResources() == null) {
page.setResources(new PDResources());
changed = true;
}
}
if (!changed) {
return pdf;
}
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.save(out);
return out.toByteArray();
}
} catch (IOException e) {
throw new ZugferdConversionException("Das PDF konnte nicht gelesen werden: " + e.getMessage(), e);
}
}
private Invoice buildInvoice(InvoiceMetadata m) {
LocalDate delivery = m.deliveryDate() != null ? m.deliveryDate() : m.issueDate();
LocalDate due = m.dueDate() != null ? m.dueDate() : m.issueDate().plusDays(14);
String currency = m.currency() != null && !m.currency().isBlank() ? m.currency() : "EUR";
Invoice invoice = new Invoice()
.setDocumentName("Rechnung")
.setBusinessProcessId(BUSINESS_PROCESS)
.setNumber(m.invoiceNumber())
.setIssueDate(toDate(m.issueDate()))
.setDeliveryDate(toDate(delivery))
.setDueDate(toDate(due))
.setCurrency(currency)
.setSender(toSender(m))
.setRecipient(toTradeParty(m.recipient()));
if (m.paymentTerms() != null && !m.paymentTerms().isBlank()) {
invoice.setPaymentTermDescription(m.paymentTerms());
}
if (m.buyerReference() != null && !m.buyerReference().isBlank()) {
// Käuferreferenz / Leitweg-ID (BT-10)
invoice.setReferenceNumber(m.buyerReference().trim());
}
for (InvoiceMetadata.LineItem li : m.items()) {
// Einheit "C62" = Stück (UN/ECE Recommendation 20)
Product product = new Product(li.description(), "", "C62", li.vatPercent());
invoice.addItem(new Item(product, li.unitPriceNet(), li.quantity()));
}
return invoice;
}
/** Rechnungssteller inkl. Verkäufer-Kontakt (BG-6) und Zahlungsverbindung (BG-16). */
private TradeParty toSender(InvoiceMetadata m) {
InvoiceMetadata.Party p = m.sender();
TradeParty party = toTradeParty(p);
party.setContact(new Contact(p.name(), p.phone(), p.email()));
if (m.iban() != null && !m.iban().isBlank()) {
String iban = m.iban().replaceAll("\\s", "").toUpperCase();
party.addBankDetails(m.bic() != null && !m.bic().isBlank()
? new BankDetails(iban, m.bic().trim().toUpperCase())
: new BankDetails(iban));
}
return party;
}
private TradeParty toTradeParty(InvoiceMetadata.Party p) {
TradeParty party = new TradeParty(p.name(), p.street(), p.zip(), p.city(), p.countryCode());
if (p.vatId() != null && !p.vatId().isBlank()) {
party.addVATID(p.vatId());
}
if (p.email() != null && !p.email().isBlank()) {
party.setEmail(p.email());
party.addUriUniversalCommunicationID(new SchemedID(EAS_EMAIL, p.email()));
}
return party;
}
private byte[] embedXmlIntoPdf(byte[] sourcePdf, Invoice invoice) {
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
IZUGFeRDExporter exporter = loadExporter(sourcePdf)) {
exporter.setProducer(PRODUCER)
.setCreator(PRODUCER)
.setProfile(Profiles.getByName(PROFILE))
.setTransaction(invoice);
exporter.export(out);
return out.toByteArray();
} catch (IOException | RuntimeException e) {
// Mustang wirft bei problematischen PDFs auch unchecked Exceptions
// ohne diesen Catch würde daraus ein HTTP 500 statt einer Fehlermeldung.
throw new ZugferdConversionException(
"Das PDF konnte nicht in eine ZUGFeRD-Rechnung umgewandelt werden: " + e.getMessage(), e);
}
}
/**
* Versucht zuerst die automatische PDF/A-Erkennung; ist das PDF kein PDF/A
* (der Normalfall bei ERP-Ausdrucken), wird es tolerant als PDF/A-1
* interpretiert und nach PDF/A-3 konvertiert.
*/
private IZUGFeRDExporter loadExporter(byte[] sourcePdf) throws IOException {
ZUGFeRDExporterFromPDFA exporter = new ZUGFeRDExporterFromPDFA();
try {
exporter.load(new ByteArrayInputStream(sourcePdf));
return exporter;
} catch (IOException | IllegalArgumentException e) {
// Nicht exporter.close(): vor erfolgreichem load() hält er keine Ressourcen
// und close() würde eine RuntimeException werfen.
log.info("Eingabe ist kein PDF/A, konvertiere tolerant: {}", e.getMessage());
ZUGFeRDExporterFromA1 fallback = new ZUGFeRDExporterFromA1();
fallback.ignorePDFAErrors();
fallback.load(new ByteArrayInputStream(sourcePdf));
return fallback;
}
}
private byte[] generateXml(Invoice invoice) {
ZUGFeRD2PullProvider provider = new ZUGFeRD2PullProvider();
provider.setProfile(Profiles.getByName(PROFILE));
provider.generateXML(invoice);
return provider.getXML();
}
/**
* Packt die übergebenen Einträge (abwechselnd Dateiname als String und Inhalt
* als byte[]) in eine ZIP-Datei.
*/
private byte[] zip(Object... namesAndContents) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(bos)) {
for (int i = 0; i < namesAndContents.length; i += 2) {
zos.putNextEntry(new ZipEntry((String) namesAndContents[i]));
zos.write((byte[]) namesAndContents[i + 1]);
zos.closeEntry();
}
zos.finish();
return bos.toByteArray();
} catch (IOException e) {
throw new ZugferdConversionException("ZIP-Datei konnte nicht erstellt werden.", e);
}
}
private static String sanitizeFileName(String name) {
String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_");
return cleaned.isBlank() ? "rechnung" : cleaned;
}
private static Date toDate(LocalDate date) {
return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
}
@@ -1,38 +0,0 @@
package de.assecutor.pdftool.zugferd;
import org.mustangproject.validator.ZUGFeRDValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Validiert ZUGFeRD-Rechnungen (PDF oder XML) mit dem Mustang-Validator gegen
* XSD-Schema und die EN16931-Schematron-Regeln sowie das PDF gegen PDF/A-3
* (veraPDF). Liefert das Prüfergebnis samt XML-Prüfbericht.
*/
@Service
public class ZugferdValidationService {
private static final Logger log = LoggerFactory.getLogger(ZugferdValidationService.class);
public ValidationResult validate(byte[] content, String fileName) {
try {
// ZUGFeRDValidator hält Zustand pro Prüfung und ist nicht threadsicher,
// daher pro Aufruf eine neue Instanz
ZUGFeRDValidator validator = new ZUGFeRDValidator();
String report = validator.validate(content, fileName);
boolean valid = validator.wasCompletelyValid();
if (!valid) {
log.warn("ZUGFeRD-Validierung von {} fehlgeschlagen:\n{}", fileName, report);
}
return new ValidationResult(valid, report);
} catch (RuntimeException e) {
log.error("Validierung von {} nicht durchführbar", fileName, e);
return new ValidationResult(false,
"<validation><error>Validierung nicht durchführbar: " + e.getMessage() + "</error></validation>");
}
}
public record ValidationResult(boolean valid, String reportXml) {
}
}
+20 -2
View File
@@ -1,11 +1,29 @@
spring.application.name=pdf-tool spring.application.name=pdf-tool
server.port=${SERVER_PORT:8083} server.port=${SERVER_PORT:8083}
# Session erst nach 12 h Inaktivität verwerfen: Der Standard von 30 Minuten
# führte dazu, dass Vaadin nach längerer Untätigkeit (Tab inaktiv, Standby)
# die Seite neu lädt und ungespeicherte Canvas-Änderungen verloren gehen.
server.servlet.session.timeout=12h
# Eigener Name für den Session-Cookie: Der Standardname JSESSIONID gilt pro
# Hostname (nicht pro Port). Andere lokal laufende Java-Anwendungen auf
# demselben Rechner überschreiben sich sonst gegenseitig den Cookie, und die
# Session des PDF-Tools ist scheinbar sofort "abgelaufen".
server.servlet.session.cookie.name=PDFTOOL_SESSION
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering) # Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
pdftool.version=@project.version@ pdftool.version=@project.version@
spring.servlet.multipart.max-file-size=25MB # PDF Service: erzeugt aus dem gerenderten Rechnungs-PDF die ZUGFeRD-E-Rechnung
spring.servlet.multipart.max-request-size=30MB # (eigenständiges Projekt "PDF Service", POST {pdfservice.url}/invoices/sign)
pdfservice.url=${PDF_SERVICE_URL:http://localhost:8084/api/v1}
# Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit
# eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB
# überschreiten; ein abgelehnter Request führt im Browser sonst zu einem
# Vaadin-Kommunikationsfehler mit anschließendem Seiten-Reload.
server.tomcat.max-http-form-post-size=30MB
vaadin.launch-browser=false vaadin.launch-browser=false
@@ -1,133 +0,0 @@
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;
}
}
+40
View File
@@ -0,0 +1,40 @@
// This TypeScript configuration file is generated by vaadin-maven-plugin.
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"_version": "9.1",
"compilerOptions": {
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"ignoreDeprecations": "5.0",
"baseUrl": "src/main/frontend",
"paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"],
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
"Frontend/*": ["*"]
}
},
"include": [
"src/main/frontend/**/*",
"types.d.ts"
],
"exclude": [
"src/main/frontend/generated/jar-resources/**"
]
}
Vendored
+17
View File
@@ -0,0 +1,17 @@
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
// You can not directly import your different static files into TypeScript,
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
declare module '*.css?inline' {
import type { CSSResultGroup } from 'lit';
const content: CSSResultGroup;
export default content;
}
// Allow any CSS Custom Properties
declare module 'csstype' {
interface Properties {
[index: `--${string}`]: any;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { UserConfigFn } from 'vite';
import { overrideVaadinConfig } from './vite.generated';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
});
export default overrideVaadinConfig(customConfig);
+858
View File
@@ -0,0 +1,858 @@
/**
* NOTICE: this is an auto-generated file
*
* This file has been generated by the `flow:prepare-frontend` maven goal.
* This file will be overwritten on every run. Any custom changes should be made to vite.config.ts
*/
import path from 'path';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, Stats } from 'fs';
import { createHash } from 'crypto';
import * as net from 'net';
import { processThemeResources } from './target/plugins/application-theme-plugin/theme-handle.js';
import { rewriteCssUrls } from './target/plugins/theme-loader/theme-loader-utils.js';
import { addFunctionComponentSourceLocationBabel } from './target/plugins/react-function-location-plugin/react-function-location-plugin.js';
import settings from './target/vaadin-dev-server-settings.json';
import {
AssetInfo,
ChunkInfo,
build,
defineConfig,
mergeConfig,
OutputOptions,
PluginOption,
InlineConfig,
UserConfigFn
} from 'vite';
import { getManifest, type ManifestTransform } from 'workbox-build';
import * as rollup from 'rollup';
import brotli from 'rollup-plugin-brotli';
import checker from 'vite-plugin-checker';
import postcssLit from './target/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import { createRequire } from 'module';
import { visualizer } from 'rollup-plugin-visualizer';
import reactPlugin from '@vitejs/plugin-react';
// Make `require` compatible with ES modules
const require = createRequire(import.meta.url);
const appShellUrl = '.';
const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput);
const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput);
const devBundle = !!process.env.devBundle;
const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder);
const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder);
const projectPackageJsonFile = path.resolve(__dirname, 'package.json');
const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder;
const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput);
const statsFile = path.resolve(statsFolder, 'stats.json');
const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html');
const nodeModulesFolder = path.resolve(__dirname, 'node_modules');
const webComponentTags = '';
const projectIndexHtml = path.resolve(frontendFolder, 'index.html');
const projectStaticAssetsFolders = [
path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'),
path.resolve(__dirname, 'src', 'main', 'resources', 'static'),
frontendFolder
];
// Folders in the project which can contain application themes
const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.resolve(folder, settings.themeFolder));
const themeOptions = {
devMode: false,
useDevBundle: devBundle,
// The following matches folder 'frontend/generated/themes/'
// (not 'frontend/themes') for theme in JAR that is copied there
themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder),
themeProjectFolders: themeProjectFolders,
projectStaticAssetsOutputFolder: devBundle
? path.resolve(devBundleFolder, '../assets')
: path.resolve(__dirname, settings.staticOutput),
frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder)
};
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
// Block debug and trace logs.
console.trace = () => {};
console.debug = () => {};
function injectManifestToSWPlugin(): rollup.Plugin {
const rewriteManifestIndexHtmlUrl: ManifestTransform = (manifest) => {
const indexEntry = manifest.find((entry) => entry.url === 'index.html');
if (indexEntry) {
indexEntry.url = appShellUrl;
}
return { manifest, warnings: [] };
};
return {
name: 'vaadin:inject-manifest-to-sw',
async transform(code, id) {
if (/sw\.(ts|js)$/.test(id)) {
const { manifestEntries } = await getManifest({
globDirectory: buildOutputFolder,
globPatterns: ['**/*'],
globIgnores: ['**/*.br', 'pwa-icons/**'],
manifestTransforms: [rewriteManifestIndexHtmlUrl],
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb,
});
return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries));
}
}
};
}
function buildSWPlugin(opts: { devMode: boolean }): PluginOption {
let buildConfig: InlineConfig;
let buildOutput: rollup.RollupOutput;
const devMode = opts.devMode;
return {
name: 'vaadin:build-sw',
enforce: 'post',
async configResolved(viteConfig) {
buildConfig = {
base: viteConfig.base,
root: viteConfig.root,
mode: viteConfig.mode,
resolve: viteConfig.resolve,
define: {
...viteConfig.define,
'process.env.NODE_ENV': JSON.stringify(viteConfig.mode),
},
build: {
write: !devMode,
minify: viteConfig.build.minify,
outDir: viteConfig.build.outDir,
sourcemap: viteConfig.command === 'serve' || viteConfig.build.sourcemap,
emptyOutDir: false,
modulePreload: false,
target: ['safari15', 'es2022'],
rollupOptions: {
input: {
sw: settings.clientServiceWorkerSource
},
output: {
exports: 'none',
entryFileNames: 'sw.js',
inlineDynamicImports: true,
},
},
},
};
},
async buildStart() {
if (devMode) {
buildOutput = await build(buildConfig) as rollup.RollupOutput;
}
},
async load(id) {
if (id.endsWith('sw.js')) {
return buildOutput.output[0].code;
}
},
async closeBundle() {
if (!devMode) {
await build({
...buildConfig,
plugins: [injectManifestToSWPlugin(), brotli()]
});
}
},
};
}
function statsExtracterPlugin(): PluginOption {
function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) {
const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json');
if (existsSync(themeJson)) {
const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
themeJsonContents[themeName] = themeJsonContent;
const themeJsonObject = JSON.parse(themeJsonContent);
if (themeJsonObject.parent) {
collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent);
}
}
}
return {
name: 'vaadin:stats',
enforce: 'post',
async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) {
const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : []));
const nodeModulesFolders = modules
.map((id) => id.replace(/\\/g, '/'))
.filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/')))
.map((id) => id.substring(nodeModulesFolder.length + 1));
const npmModules = nodeModulesFolders
.map((id) => id.replace(/\\/g, '/'))
.map((id) => {
const parts = id.split('/');
if (id.startsWith('@')) {
return parts[0] + '/' + parts[1];
} else {
return parts[0];
}
})
.sort()
.filter((value, index, self) => self.indexOf(value) === index);
const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)]));
const cvdls = Object.fromEntries(
npmModules
.filter((module) => getCvdlName(module) != null)
.map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }])
);
mkdirSync(path.dirname(statsFile), { recursive: true });
const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' }));
const entryScripts = Object.values(bundle)
.filter((bundle) => bundle.isEntry)
.map((bundle) => bundle.fileName);
const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html');
const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' });
const generatedIndexData: string = readFileSync(generatedIndexHtml, {
encoding: 'utf-8'
});
const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''));
const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '');
const rowsGenerated: string[] = [];
generatedIndexRows.forEach((row) => {
if (!customIndexRows.has(row)) {
rowsGenerated.push(row);
}
});
//After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport
const parseImports = (filename: string, result: Set<string>): void => {
const content: string = readFileSync(filename, { encoding: 'utf-8' });
const lines = content.split('\n');
const staticImports = lines
.filter((line) => line.startsWith('import '))
.map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'")))
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
const dynamicImports = lines
.filter((line) => line.includes('import('))
.map((line) => line.replace(/.*import\(/, ''))
.map((line) => line.split(/'/)[1])
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
staticImports.forEach((staticImport) => result.add(staticImport));
dynamicImports.map((dynamicImport) => {
const importedFile = path.resolve(path.dirname(filename), dynamicImport);
parseImports(importedFile, result);
});
};
const generatedImportsSet = new Set<string>();
parseImports(
path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'),
generatedImportsSet
);
const generatedImports = Array.from(generatedImportsSet).sort();
const frontendFiles: Record<string, string> = {};
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
&& id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//);
const isGeneratedWebComponentResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
&& id.match(/.*\/flow\/web-components\//);
const isFrontendResourceCollected = (id: string) =>
!id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
|| isThemeComponentsResource(id)
|| isGeneratedWebComponentResource(id);
// collects project's frontend resources in frontend folder, excluding
// 'generated' sub-folder, except for legacy shadow DOM stylesheets
// packaged in `theme/components/` folder
// and generated web component resources in `flow/web-components` folder.
modules
.map((id) => id.replace(/\\/g, '/'))
.filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/')))
.filter(isFrontendResourceCollected)
.map((id) => id.substring(frontendFolder.length + 1))
.map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line))
.forEach((line: string) => {
// \r\n from windows made files may be used so change to \n
const filePath = path.resolve(frontendFolder, line);
if (projectFileExtensions.includes(path.extname(filePath))) {
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
});
// collects frontend resources from the JARs
generatedImports
.filter((line: string) => line.includes('generated/jar-resources'))
.forEach((line: string) => {
let filename = line.substring(line.indexOf('generated'));
// \r\n from windows made files may be used ro remove to be only \n
const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
const fileKey = line.substring(line.indexOf('jar-resources/') + 14);
frontendFiles[fileKey] = hash;
});
// collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/
// and files already in frontendFiles.
let frontendFolderAlias = "Frontend";
generatedImports
.filter((line: string) => line.startsWith(frontendFolderAlias + '/'))
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/'))
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/'))
.map((line) => line.substring(frontendFolderAlias.length + 1))
.filter((line: string) => !frontendFiles[line])
.forEach((line: string) => {
const filePath = path.resolve(frontendFolder, line);
if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) {
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
});
// If a index.ts exists hash it to be able to see if it changes.
if (existsSync(path.resolve(frontendFolder, 'index.ts'))) {
const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
const themeJsonContents: Record<string, string> = {};
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
if (existsSync(themesFolder)) {
readdirSync(themesFolder).forEach((themeFolder) => {
const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json');
if (existsSync(themeJson)) {
themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
}
});
}
collectThemeJsonsInFrontend(themeJsonContents, settings.themeName);
let webComponents: string[] = [];
if (webComponentTags) {
webComponents = webComponentTags.split(';');
}
const stats = {
packageJsonDependencies: projectPackageJson.dependencies,
npmModules: npmModuleAndVersion,
bundleImports: generatedImports,
frontendHashes: frontendFiles,
themeJsonContents: themeJsonContents,
entryScripts,
webComponents,
cvdlModules: cvdls,
packageJsonHash: projectPackageJson?.vaadin?.hash,
indexHtmlGenerated: rowsGenerated
};
writeFileSync(statsFile, JSON.stringify(stats, null, 1));
}
};
}
function vaadinBundlesPlugin(): PluginOption {
type ExportInfo =
| string
| {
namespace?: string;
source: string;
};
type ExposeInfo = {
exports: ExportInfo[];
};
type PackageInfo = {
version: string;
exposes: Record<string, ExposeInfo>;
};
type BundleJson = {
packages: Record<string, PackageInfo>;
};
const disabledMessage = 'Vaadin component dependency bundles are disabled.';
const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/');
let vaadinBundleJson: BundleJson;
function parseModuleId(id: string): { packageName: string; modulePath: string } {
const [scope, scopedPackageName] = id.split('/', 3);
const packageName = scope.startsWith('@') ? `${scope}/${scopedPackageName}` : scope;
const modulePath = `.${id.substring(packageName.length)}`;
return {
packageName,
modulePath
};
}
function getExports(id: string): string[] | undefined {
const { packageName, modulePath } = parseModuleId(id);
const packageInfo = vaadinBundleJson.packages[packageName];
if (!packageInfo) return;
const exposeInfo: ExposeInfo = packageInfo.exposes[modulePath];
if (!exposeInfo) return;
const exportsSet = new Set<string>();
for (const e of exposeInfo.exports) {
if (typeof e === 'string') {
exportsSet.add(e);
} else {
const { namespace, source } = e;
if (namespace) {
exportsSet.add(namespace);
} else {
const sourceExports = getExports(source);
if (sourceExports) {
sourceExports.forEach((e) => exportsSet.add(e));
}
}
}
}
return Array.from(exportsSet);
}
function getExportBinding(binding: string) {
return binding === 'default' ? '_default as default' : binding;
}
function getImportAssigment(binding: string) {
return binding === 'default' ? 'default: _default' : binding;
}
return {
name: 'vaadin:bundles',
enforce: 'pre',
apply(config, { command }) {
if (command !== 'serve') return false;
try {
const vaadinBundleJsonPath = require.resolve('@vaadin/bundles/vaadin-bundle.json');
vaadinBundleJson = JSON.parse(readFileSync(vaadinBundleJsonPath, { encoding: 'utf8' }));
} catch (e: unknown) {
if (typeof e === 'object' && (e as { code: string }).code === 'MODULE_NOT_FOUND') {
vaadinBundleJson = { packages: {} };
console.info(`@vaadin/bundles npm package is not found, ${disabledMessage}`);
return false;
} else {
throw e;
}
}
const versionMismatches: Array<{ name: string; bundledVersion: string; installedVersion: string }> = [];
for (const [name, packageInfo] of Object.entries(vaadinBundleJson.packages)) {
let installedVersion: string | undefined = undefined;
try {
const { version: bundledVersion } = packageInfo;
const installedPackageJsonFile = path.resolve(modulesDirectory, name, 'package.json');
const packageJson = JSON.parse(readFileSync(installedPackageJsonFile, { encoding: 'utf8' }));
installedVersion = packageJson.version;
if (installedVersion && installedVersion !== bundledVersion) {
versionMismatches.push({
name,
bundledVersion,
installedVersion
});
}
} catch (_) {
// ignore package not found
}
}
if (versionMismatches.length) {
console.info(`@vaadin/bundles has version mismatches with installed packages, ${disabledMessage}`);
console.info(`Packages with version mismatches: ${JSON.stringify(versionMismatches, undefined, 2)}`);
vaadinBundleJson = { packages: {} };
return false;
}
return true;
},
async config(config) {
return mergeConfig(
{
optimizeDeps: {
exclude: [
// Vaadin bundle
'@vaadin/bundles',
...Object.keys(vaadinBundleJson.packages),
'@vaadin/vaadin-material-styles'
]
}
},
config
);
},
load(rawId) {
const [path, params] = rawId.split('?');
if (!path.startsWith(modulesDirectory)) return;
const id = path.substring(modulesDirectory.length + 1);
const bindings = getExports(id);
if (bindings === undefined) return;
const cacheSuffix = params ? `?${params}` : '';
const bundlePath = `@vaadin/bundles/vaadin.js${cacheSuffix}`;
return `import { init as VaadinBundleInit, get as VaadinBundleGet } from '${bundlePath}';
await VaadinBundleInit('default');
const { ${bindings.map(getImportAssigment).join(', ')} } = (await VaadinBundleGet('./node_modules/${id}'))();
export { ${bindings.map(getExportBinding).join(', ')} };`;
}
};
}
function themePlugin(opts: { devMode: boolean }): PluginOption {
const fullThemeOptions = { ...themeOptions, devMode: opts.devMode };
return {
name: 'vaadin:theme',
config() {
processThemeResources(fullThemeOptions, console);
},
configureServer(server) {
function handleThemeFileCreateDelete(themeFile: string, stats?: Stats) {
if (themeFile.startsWith(themeFolder)) {
const changed = path.relative(themeFolder, themeFile);
console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed);
processThemeResources(fullThemeOptions, console);
}
}
server.watcher.on('add', handleThemeFileCreateDelete);
server.watcher.on('unlink', handleThemeFileCreateDelete);
},
handleHotUpdate(context) {
const contextPath = path.resolve(context.file);
const themePath = path.resolve(themeFolder);
if (contextPath.startsWith(themePath)) {
const changed = path.relative(themePath, contextPath);
console.debug('Theme file changed', changed);
if (changed.startsWith(settings.themeName)) {
processThemeResources(fullThemeOptions, console);
}
}
},
async resolveId(id, importer) {
// force theme generation if generated theme sources does not yet exist
// this may happen for example during Java hot reload when updating
// @Theme annotation value
if (
path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer &&
!existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id))
) {
console.debug('Generate theme file ' + id + ' not existing. Processing theme resource');
processThemeResources(fullThemeOptions, console);
return;
}
if (!id.startsWith(settings.themeFolder)) {
return;
}
for (const location of [themeResourceFolder, frontendFolder]) {
const result = await this.resolve(path.resolve(location, id));
if (result) {
return result;
}
}
},
async transform(raw, id, options) {
// rewrite urls for the application theme css files
const [bareId, query] = id.split('?');
if (
(!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) ||
!bareId?.endsWith('.css')
) {
return;
}
const resourceThemeFolder = bareId.startsWith(themeFolder) ? themeFolder : themeOptions.themeResourceFolder;
const [themeName] = bareId.substring(resourceThemeFolder.length + 1).split('/');
return rewriteCssUrls(raw, path.dirname(bareId), path.resolve(resourceThemeFolder, themeName), console, opts);
}
};
}
function runWatchDog(watchDogPort: number, watchDogHost: string | undefined) {
const client = new net.Socket();
client.setEncoding('utf8');
client.on('error', function (err) {
console.log('Watchdog connection error. Terminating vite process...', err);
client.destroy();
process.exit(0);
});
client.on('close', function () {
client.destroy();
runWatchDog(watchDogPort, watchDogHost);
});
client.connect(watchDogPort, watchDogHost || 'localhost');
}
const allowedFrontendFolders = [frontendFolder, nodeModulesFolder];
function showRecompileReason(): PluginOption {
return {
name: 'vaadin:why-you-compile',
handleHotUpdate(context) {
console.log('Recompiling because', context.file, 'changed');
}
};
}
const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/;
const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i;
function preserveUsageStats() {
return {
name: 'vaadin:preserve-usage-stats',
transform(src: string, id: string) {
if (id.includes('vaadin-usage-statistics')) {
if (src.includes('vaadin-dev-mode:start')) {
const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start');
if (newSrc === src) {
console.error('Comment replacement failed to change anything');
} else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) {
console.error('New comment fails to match original regexp');
} else {
return { code: newSrc };
}
}
}
return { code: src };
}
};
}
export const vaadinConfig: UserConfigFn = (env) => {
const devMode = env.mode === 'development';
const productionMode = !devMode && !devBundle
if (devMode && process.env.watchDogPort) {
// Open a connection with the Java dev-mode handler in order to finish
// vite when it exits or crashes.
runWatchDog(parseInt(process.env.watchDogPort), process.env.watchDogHost);
}
return {
root: frontendFolder,
base: '',
publicDir: false,
resolve: {
alias: {
'@vaadin/flow-frontend': jarResourcesFolder,
Frontend: frontendFolder
},
preserveSymlinks: true
},
define: {
OFFLINE_PATH: settings.offlinePath,
VITE_ENABLED: 'true'
},
server: {
host: '127.0.0.1',
strictPort: true,
fs: {
allow: allowedFrontendFolders
}
},
build: {
minify: productionMode,
outDir: buildOutputFolder,
emptyOutDir: devBundle,
assetsDir: 'VAADIN/build',
target: ['safari15', 'es2022'],
rollupOptions: {
input: {
indexhtml: projectIndexHtml,
...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {})
},
onwarn: (warning: rollup.RollupLog, defaultHandler: rollup.LoggingFunction) => {
const ignoreEvalWarning = [
'generated/jar-resources/FlowClient.js',
'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js',
'@vaadin/charts/src/helpers.js'
];
if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id?.endsWith(id))) {
return;
}
defaultHandler(warning);
}
}
},
optimizeDeps: {
entries: [
// Pre-scan entrypoints in Vite to avoid reloading on first open
'generated/vaadin.ts'
],
exclude: [
'@vaadin/router',
'@vaadin/vaadin-license-checker',
'@vaadin/vaadin-usage-statistics',
'workbox-core',
'workbox-precaching',
'workbox-routing',
'workbox-strategies'
]
},
plugins: [
productionMode && brotli(),
devMode && vaadinBundlesPlugin(),
devMode && showRecompileReason(),
settings.offlineEnabled && buildSWPlugin({ devMode }),
!devMode && statsExtracterPlugin(),
!productionMode && preserveUsageStats(),
themePlugin({ devMode }),
postcssLit({
include: ['**/*.css', /.*\/.*\.css\?.*/],
exclude: [
`${themeFolder}/**/*.css`,
new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`),
`${themeResourceFolder}/**/*.css`,
new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`),
new RegExp('.*/.*\\?html-proxy.*')
]
}),
// The React plugin provides fast refresh and debug source info
reactPlugin({
include: '**/*.tsx',
babel: {
// We need to use babel to provide the source information for it to be correct
// (otherwise Babel will slightly rewrite the source file and esbuild generate source info for the modified file)
presets: [
[
'@babel/preset-react',
{
runtime: 'automatic',
importSource: productionMode ? 'react' : 'Frontend/generated/jsx-dev-transform',
development: !productionMode
}
]
],
// React writes the source location for where components are used, this writes for where they are defined
plugins: [
!productionMode && addFunctionComponentSourceLocationBabel(),
[
'module:@preact/signals-react-transform',
{
mode: 'all' // Needed to include translations which do not use something.value
}
]
].filter(Boolean)
}
}),
{
name: 'vaadin:force-remove-html-middleware',
configureServer(server) {
return () => {
server.middlewares.stack = server.middlewares.stack.filter((mw) => {
const handleName = `${mw.handle}`;
return !handleName.includes('viteHtmlFallbackMiddleware');
});
};
},
},
hasExportedWebComponents && {
name: 'vaadin:inject-entrypoints-to-web-component-html',
transformIndexHtml: {
order: 'pre',
handler(_html, { path, server }) {
if (path !== '/web-component.html') {
return;
}
return [
{
tag: 'script',
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
injectTo: 'head'
}
];
}
}
},
{
name: 'vaadin:inject-entrypoints-to-index-html',
transformIndexHtml: {
order: 'pre',
handler(_html, { path, server }) {
if (path !== '/index.html') {
return;
}
const scripts = [];
if (devMode) {
scripts.push({
tag: 'script',
attrs: { type: 'module', src: `/generated/vite-devmode.ts`, onerror: "document.location.reload()" },
injectTo: 'head'
});
}
scripts.push({
tag: 'script',
attrs: { type: 'module', src: '/generated/vaadin.ts' },
injectTo: 'head'
});
return scripts;
}
}
},
checker({
typescript: true
}),
productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile })
]
};
};
export const overrideVaadinConfig = (customConfig: UserConfigFn) => {
return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env)));
};
function getVersion(module: string): string {
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version;
}
function getCvdlName(module: string): string {
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName;
}