Compare commits
8
Commits
102b485794
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0163f21e80 | ||
|
|
2eef2c1052 | ||
|
|
7d82aeb0f5 | ||
|
|
0d5620ae89 | ||
|
|
8efadb3c79 | ||
|
|
7c629f189a | ||
|
|
d07b4832c6 | ||
|
|
1fb6444444 |
No files matched your search
@@ -61,3 +61,42 @@ Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei
|
|||||||
| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" |
|
| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" |
|
||||||
| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" |
|
| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" |
|
||||||
| Leer | (überspringen) | — | — | — | — |
|
| Leer | (überspringen) | — | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows-Installer (Inno Setup)
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Installer-EXE erstellen, die:
|
||||||
|
- Von einem Windows SMB-Share aus gestartet werden kann (self-contained .exe)
|
||||||
|
- Das Programm lokal unter `%ProgramFiles%\AnwesenheitPDF\` installiert
|
||||||
|
- Einen Startmenü-Eintrag anlegt (optional: Desktop-Verknüpfung)
|
||||||
|
- Einen Uninstaller registriert (Programme und Features)
|
||||||
|
|
||||||
|
### Tool: Inno Setup 6
|
||||||
|
- Free, open-source, weit verbreitet
|
||||||
|
- Erzeugt eine einzelne selbstextrahierende .exe (ideal für SMB-Share)
|
||||||
|
- Unterstützt German.isl (deutschsprachiger Wizard)
|
||||||
|
- ISCC.exe (Compiler) muss auf Build-Maschine installiert sein
|
||||||
|
- Installation: `winget install --id JRSoftware.InnoSetup -e -s winget -i`
|
||||||
|
|
||||||
|
### Dateien
|
||||||
|
- `installer\AnwesenheitPDF.iss` — Inno Setup Script
|
||||||
|
- `build.bat` — um Installer-Schritt erweitert (ruft ISCC auf)
|
||||||
|
- Ausgabe: `dist\AnwesenheitPDF-Setup-x.y.z.exe`
|
||||||
|
|
||||||
|
### Ablauf build.bat (neu: Schritt 4)
|
||||||
|
1. pip install
|
||||||
|
2. update_version.py (liest Version aus config.json)
|
||||||
|
3. PyInstaller → dist\AnwesenheitPDF\
|
||||||
|
4. Version aus config.json extrahieren → ISCC aufrufen → dist\AnwesenheitPDF-Setup-x.y.z.exe
|
||||||
|
|
||||||
|
### Install-Ziel
|
||||||
|
- `{autopf}\AnwesenheitPDF\` (= Program Files, UAC-geschützt)
|
||||||
|
- Requires admin (PrivilegesRequired=admin)
|
||||||
|
- Startmenü: `{group}\AnwesenheitPDF`
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
- Aktuelle Versionsnummer aus config.json bestaetigen.
|
||||||
|
- Release-Notes-Dokument im Projekt-Root anlegen.
|
||||||
|
- Release-Notes-Datei zusaetzlich zum Installer in `update_check_path` kopieren.
|
||||||
@@ -13,3 +13,4 @@ tests/fixtures/**/*.xlsx
|
|||||||
tests/fixtures/**/*.xlsm
|
tests/fixtures/**/*.xlsm
|
||||||
tests/fixtures/**/reference/*.pdf
|
tests/fixtures/**/reference/*.pdf
|
||||||
tests/fixtures/**/diff/
|
tests/fixtures/**/diff/
|
||||||
|
*.log
|
||||||
@@ -37,6 +37,59 @@ Vor dem ersten Start müssen zwei Werte in `config.json` eingetragen werden:
|
|||||||
|
|
||||||
Alle anderen Felder sind vorbelegt und müssen nur bei abweichender Excel-Struktur angepasst werden.
|
Alle anderen Felder sind vorbelegt und müssen nur bei abweichender Excel-Struktur angepasst werden.
|
||||||
|
|
||||||
|
### Erweiterte PDF-Optionen
|
||||||
|
|
||||||
|
Diese Schalter steuern das interne Verhalten beim PDF-Befüllen und müssen in der Regel nicht verändert werden.
|
||||||
|
|
||||||
|
#### `pdf_use_display_values` (Standard: `true`)
|
||||||
|
|
||||||
|
Steuert, welche Werte in Dropdown- und Auswahlfelder geschrieben werden.
|
||||||
|
|
||||||
|
| Wert | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `true` | Lesbare Anzeigewerte (z. B. `"X = anwesend"`, `"mind. 6 h"`, `"8,60"`) werden ins PDF geschrieben. Der PDF-Viewer zeigt den Text direkt an. |
|
||||||
|
| `false` | Rohe Export-Werte (z. B. `"X"`, `"6"`, `"1"`) werden verwendet — so wie es das JavaScript im Originalformular intern macht. Sinnvoll, wenn ein externer Viewer (z. B. Adobe Acrobat) die Werte selbst aufzulösen soll. |
|
||||||
|
|
||||||
|
#### `pdf_need_appearances` (Standard: `false`)
|
||||||
|
|
||||||
|
Steuert das `NeedAppearances`-Flag in der erzeugten PDF-Datei.
|
||||||
|
|
||||||
|
| Wert | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `false` | Der PDF-Renderer nutzt die im PDF gespeicherten Appearance-Streams (AP) direkt. Buttons, Footer und Summenfelder werden korrekt angezeigt. **Empfohlene Einstellung.** |
|
||||||
|
| `true` | Der PDF-Viewer regeneriert alle Darstellungen aus den Felddefinitionen (`/DA`). Kann helfen, wenn ein externer Viewer einzelne Felder leer anzeigt — kann aber dazu führen, dass Buttons und Footer ausgeblendet werden. |
|
||||||
|
|
||||||
|
> **Hinweis:** Die Summenfelder erhalten beim Generieren eigene Appearance-Streams (Zwei-Pass-Schreibstrategie), sodass `pdf_need_appearances: false` für alle Felder funktioniert.
|
||||||
|
|
||||||
|
#### `regression` (Objekt)
|
||||||
|
|
||||||
|
Einstellungen für den automatisierten Regressions-Bildvergleich (`pytest`). Alle Unterfelder befinden sich in diesem Objekt.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"regression": {
|
||||||
|
"enabled": false,
|
||||||
|
"mask_footer": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `regression.enabled` (Standard: `false`)
|
||||||
|
|
||||||
|
Aktiviert oder deaktiviert den Bildvergleich.
|
||||||
|
|
||||||
|
| Wert | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `false` | Regression-Tests werden übersprungen (`pytest.skip`). **Standard.** |
|
||||||
|
| `true` | Bildvergleich wird ausgeführt. Erfordert Referenz-PDFs in `tests/fixtures/`. |
|
||||||
|
|
||||||
|
##### `regression.mask_footer` (Standard: `true`)
|
||||||
|
|
||||||
|
Steuert, ob der untere Bereich (Footer) beim Pixel-Vergleich ignoriert wird.
|
||||||
|
|
||||||
|
| Wert | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `true` | Die untere ~20% jeder PDF-Seite wird ignoriert. Sinnvoll, da Template-PDFs je nach Vorlage unterschiedlich viele Footer-Zeilen (2 oder 3) haben können. |
|
||||||
|
| `false` | Vollständiger Pixel-by-Pixel-Vergleich inkl. Footer-Bereich. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
## Verzeichnisstruktur
|
||||||
@@ -150,6 +203,19 @@ Die deutschen Monatsabkürzungen entsprechen den Tabellenblatt-Namen in der Exce
|
|||||||
| `Nov` | November |
|
| `Nov` | November |
|
||||||
| `Dez` | Dezember |
|
| `Dez` | Dezember |
|
||||||
|
|
||||||
|
### Sonderfall `P = Praktikum`
|
||||||
|
|
||||||
|
Wenn im Excel-Sheet für einen Tag `P` steht, bleibt der Status im PDF `P`, aber die Stundenfelder werden mit 6 Stunden befüllt:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `anwesend.N` | `P` |
|
||||||
|
| `ist.N` | `6,00` |
|
||||||
|
| `plan.N` | `6` |
|
||||||
|
| `pauschale_5.N` | `1` |
|
||||||
|
|
||||||
|
`P = Praktikum` wird damit wie ein regulärer Anwesenheitstag mit 6 Stunden abgerechnet und zählt auch für die 5-Euro-Pauschale.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ausgabe
|
## Ausgabe
|
||||||
@@ -198,3 +264,48 @@ Das Programm prüft vor jeder Generierung:
|
|||||||
7. Headerzeile mit `Name, Vorname` gefunden
|
7. Headerzeile mit `Name, Vorname` gefunden
|
||||||
8. Tagesspalten ab Spaltenindex 9 vorhanden
|
8. Tagesspalten ab Spaltenindex 9 vorhanden
|
||||||
9. PDF-Template unter `template/sab.pdf` erreichbar
|
9. PDF-Template unter `template/sab.pdf` erreichbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows-Installer erstellen
|
||||||
|
|
||||||
|
Der Build-Prozess erzeugt neben dem Programm auch einen Windows-Installer (`.exe`), der vom SMB-Netzlaufwerk gestartet werden kann.
|
||||||
|
|
||||||
|
### Voraussetzung: Inno Setup 6
|
||||||
|
|
||||||
|
Inno Setup 6 muss auf der Build-Maschine installiert sein. Installation über `winget`:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
winget install --id JRSoftware.InnoSetup -e -s winget -i
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ manueller Download: <https://jrsoftware.org/isinfo.php>
|
||||||
|
|
||||||
|
### Installer bauen
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.bat` führt alle vier Schritte automatisch aus:
|
||||||
|
|
||||||
|
1. Abhängigkeiten installieren (`pip install`)
|
||||||
|
2. Version aktualisieren (`update_version.py`)
|
||||||
|
3. Programm packen (`PyInstaller` → `dist\AnwesenheitPDF\`)
|
||||||
|
4. Installer kompilieren (`ISCC.exe` → `dist\AnwesenheitPDF-Setup-x.y.z.exe`)
|
||||||
|
|
||||||
|
Ist Inno Setup **nicht** installiert, wird Schritt 4 mit einer Warnung übersprungen — der PyInstaller-Output in `dist\AnwesenheitPDF\` ist trotzdem nutzbar.
|
||||||
|
|
||||||
|
### Installer verteilen
|
||||||
|
|
||||||
|
Die fertige Datei `dist\AnwesenheitPDF-Setup-x.y.z.exe` auf das SMB-Netzlaufwerk kopieren. Von dort kann sie direkt gestartet werden — ohne vorherige lokale Kopie.
|
||||||
|
|
||||||
|
### Was der Installer macht
|
||||||
|
|
||||||
|
| Aktion | Details |
|
||||||
|
|---|---|
|
||||||
|
| Installationspfad | `%ProgramFiles%\AnwesenheitPDF\` (Admin erforderlich) |
|
||||||
|
| Startmenü-Eintrag | `AnwesenheitPDF` |
|
||||||
|
| Desktop-Verknüpfung | Optional (Checkbox im Wizard, Standard: deaktiviert) |
|
||||||
|
| Uninstaller | Registriert unter *Programme und Features* |
|
||||||
|
| Windows-Version | Windows 10 oder höher |
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## Version 1.0.17 (2026-05-21)
|
||||||
|
|
||||||
|
### Zusammenfassung
|
||||||
|
- Lokale Excel-Datei waehlbar ueber Dropdown (input/).
|
||||||
|
- Neuer Update-Button neben dem Dropdown (nur bei neuerer Quelldatei sichtbar).
|
||||||
|
- Quelle nutzt nun Netzordner mit mehreren Excel-Dateien.
|
||||||
|
- Dateiauswahl aus Quelle automatisch: aktuelles Jahr bevorzugt, sonst neuestes Jahr.
|
||||||
|
- Pruefung auf Updates beim Start und danach alle 60 Sekunden.
|
||||||
|
- Bei fehlender Verbindung: Warnsymbol in GUI.
|
||||||
|
- Beim Update: Ueberschreib-Hinweis mit Abbruchoption; nach Bestaetigung Download nach input/ und automatische Auswahl.
|
||||||
|
- Bugfix Praktikum: Tage mit Status `P` werden in der PDF jetzt wie ein 6h-Anwesenheitstag behandelt, inklusive `mind. 6 h` und Pauschale `8,60`.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build-Script fuer AnwesenheitPDF.exe
|
REM Build-Script fuer AnwesenheitPDF.exe + Windows-Installer
|
||||||
REM
|
REM
|
||||||
REM Ergebnis:
|
REM Ergebnis:
|
||||||
REM build/ - temporaere PyInstaller-Artefakte
|
REM build/ - temporaere PyInstaller-Artefakte
|
||||||
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
|
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
|
||||||
|
REM + AnwesenheitPDF-Setup-x.y.z.exe (Windows-Installer)
|
||||||
REM
|
REM
|
||||||
REM Voraussetzungen:
|
REM Voraussetzungen:
|
||||||
REM pip install -r requirements-dev.txt
|
REM pip install -r requirements-dev.txt
|
||||||
|
REM Inno Setup 6 installiert (https://jrsoftware.org/isinfo.php)
|
||||||
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ echo.
|
|||||||
echo === AnwesenheitPDF Build ===
|
echo === AnwesenheitPDF Build ===
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
echo [1/3] Abhaengigkeiten installieren...
|
echo [1/4] Abhaengigkeiten installieren...
|
||||||
pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet
|
pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo [FEHLER] pip install fehlgeschlagen.
|
echo [FEHLER] pip install fehlgeschlagen.
|
||||||
@@ -22,7 +24,7 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo [2/3] Version aktualisieren...
|
echo [2/4] Version aktualisieren...
|
||||||
python update_version.py
|
python update_version.py
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
echo [FEHLER] Version-Update fehlgeschlagen.
|
echo [FEHLER] Version-Update fehlgeschlagen.
|
||||||
@@ -30,7 +32,7 @@ if %ERRORLEVEL% neq 0 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
echo [3/3] Build starten...
|
echo [3/4] Build starten...
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
python -m PyInstaller ^
|
python -m PyInstaller ^
|
||||||
@@ -59,11 +61,76 @@ if not exist "dist\AnwesenheitPDF\template" mkdir "dist\AnwesenheitPDF\template"
|
|||||||
if not exist "dist\AnwesenheitPDF\output" mkdir "dist\AnwesenheitPDF\output"
|
if not exist "dist\AnwesenheitPDF\output" mkdir "dist\AnwesenheitPDF\output"
|
||||||
if exist "template\sab.pdf" copy /Y "template\sab.pdf" "dist\AnwesenheitPDF\template\sab.pdf" >nul
|
if exist "template\sab.pdf" copy /Y "template\sab.pdf" "dist\AnwesenheitPDF\template\sab.pdf" >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [4/4] Windows-Installer erstellen...
|
||||||
|
|
||||||
|
REM Version aus config.json lesen (Format: "x.y.z+hash" -> nur "x.y.z" verwenden)
|
||||||
|
for /f "tokens=2 delims=:," %%v in ('findstr /c:"\"version\"" config.json') do (
|
||||||
|
set RAW_VERSION=%%v
|
||||||
|
)
|
||||||
|
REM Fuehrende/nachfolgende Leerzeichen und Anfuehrungszeichen entfernen, Hash abschneiden
|
||||||
|
set RAW_VERSION=%RAW_VERSION: =%
|
||||||
|
set RAW_VERSION=%RAW_VERSION:"=%
|
||||||
|
for /f "tokens=1 delims=+" %%c in ("%RAW_VERSION%") do set APP_VERSION=%%c
|
||||||
|
|
||||||
|
echo Version: %APP_VERSION%
|
||||||
|
|
||||||
|
REM Inno Setup Compiler suchen (Standard-Installationspfade)
|
||||||
|
set ISCC_PATH=
|
||||||
|
if exist "%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" (
|
||||||
|
set "ISCC_PATH=%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe"
|
||||||
|
) else if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" (
|
||||||
|
set "ISCC_PATH=%ProgramFiles%\Inno Setup 6\ISCC.exe"
|
||||||
|
) else if exist "%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe" (
|
||||||
|
set "ISCC_PATH=%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe"
|
||||||
|
) else (
|
||||||
|
for /f "delims=" %%i in ('where ISCC.exe 2^>nul') do set "ISCC_PATH=%%i"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%ISCC_PATH%"=="" (
|
||||||
|
echo.
|
||||||
|
echo [WARNUNG] Inno Setup 6 nicht gefunden. Installer wird NICHT erstellt.
|
||||||
|
echo Download: https://jrsoftware.org/isinfo.php
|
||||||
|
echo Nach der Installation Inno Setup 6 neu starten und build.bat erneut ausfuehren.
|
||||||
|
echo.
|
||||||
|
goto build_done
|
||||||
|
)
|
||||||
|
|
||||||
|
"%ISCC_PATH%" /DMyAppVersion=%APP_VERSION% installer\AnwesenheitPDF.iss > build_iscc.log 2>&1
|
||||||
|
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [FEHLER] Installer-Erstellung fehlgeschlagen.
|
||||||
|
echo.
|
||||||
|
type build_iscc.log
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [OK] Installer erstellt: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [5/5] Installer und Release Notes zu update_check_path deployen...
|
||||||
|
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $config = Get-Content -Raw -Encoding UTF8 'config.json' | ConvertFrom-Json; $updatePath = $config.update_check_path; if ([string]::IsNullOrWhiteSpace($updatePath)) { Write-Host '[WARNUNG] update_check_path nicht in config.json gefunden. Deployment uebersprungen.'; exit 0 }; $installer = Join-Path (Get-Location) 'dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe'; $releaseNotes = Join-Path (Get-Location) 'RELEASE_NOTES.md'; if (-not (Test-Path -LiteralPath $installer)) { throw ('Installer nicht gefunden: ' + $installer) }; while (-not (Test-Path -LiteralPath $updatePath)) { Write-Host ''; Write-Host ' +----------------------------------------------------------+'; Write-Host ' | [!] Deployment-Pfad nicht erreichbar |'; Write-Host ' +----------------------------------------------------------+'; Write-Host ( ' | Pfad : {0}' -f $updatePath ); Write-Host ' | |'; Write-Host ' | Bitte Netzlaufwerk / Zielpfad verbinden und dann ENTER |'; Write-Host ' | druecken. |'; Write-Host ' +----------------------------------------------------------+'; [void](Read-Host 'Eingabe'); }; Write-Host ('[OK] Deployment-Pfad erreichbar: {0}' -f $updatePath); Copy-Item -LiteralPath $installer -Destination $updatePath -Force; Write-Host ('[OK] Installer deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $installer -Leaf))); if (Test-Path -LiteralPath $releaseNotes) { Copy-Item -LiteralPath $releaseNotes -Destination $updatePath -Force; Write-Host ('[OK] Release Notes deployed zu: {0}' -f (Join-Path $updatePath (Split-Path $releaseNotes -Leaf))) } else { Write-Host ('[WARNUNG] Release Notes nicht gefunden: {0}' -f $releaseNotes) }"
|
||||||
|
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [WARNUNG] Installer-Deployment fehlgeschlagen.
|
||||||
|
goto build_done
|
||||||
|
)
|
||||||
|
|
||||||
|
:build_done
|
||||||
echo.
|
echo.
|
||||||
echo [OK] Build abgeschlossen.
|
echo [OK] Build abgeschlossen.
|
||||||
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
|
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
|
||||||
echo Template: dist\AnwesenheitPDF\template\sab.pdf
|
echo Template: dist\AnwesenheitPDF\template\sab.pdf
|
||||||
echo Output: dist\AnwesenheitPDF\output
|
echo Output: dist\AnwesenheitPDF\output
|
||||||
|
if not "%ISCC_PATH%"=="" (
|
||||||
|
echo Installer: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe
|
||||||
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ... Programm wird gestartet ...
|
echo ... Programm wird gestartet ...
|
||||||
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
|
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
|
||||||
+7
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.1+250a473",
|
"version": "1.0.17+0d5620a",
|
||||||
"antragsnummer": "100740439",
|
"antragsnummer": "100740439",
|
||||||
"ort": "Dresden",
|
"ort": "Dresden",
|
||||||
"template_pdf": "template/sab.pdf",
|
"template_pdf": "template/sab.pdf",
|
||||||
@@ -42,5 +42,10 @@
|
|||||||
"pauschale_present_value": "1",
|
"pauschale_present_value": "1",
|
||||||
"pdf_use_display_values": true,
|
"pdf_use_display_values": true,
|
||||||
"pdf_need_appearances": false,
|
"pdf_need_appearances": false,
|
||||||
"excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx"
|
"regression": {
|
||||||
|
"enabled": false,
|
||||||
|
"mask_footer": true
|
||||||
|
},
|
||||||
|
"excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx",
|
||||||
|
"update_check_path": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\02_Abrechnung Aufwand und Fahrtgeld\\Automatisierung\\"
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,15 @@
|
|||||||
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
|
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
|
||||||
# Build-Artefakte: build/ (temporaer, nicht committen)
|
# Build-Artefakte: build/ (temporaer, nicht committen)
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -99,6 +101,167 @@ def _count_output_pdfs(config: Dict[str, Any]) -> int:
|
|||||||
return len(list(out_dir.glob("*.pdf")))
|
return len(list(out_dir.glob("*.pdf")))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Update-check helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UPDATE_CHECK_FILE = _runtime_dir() / "update_check.json"
|
||||||
|
_INSTALLER_PATTERN = re.compile(r"^AnwesenheitPDF-Setup-(\d+\.\d+\.\d+)\.exe$", re.IGNORECASE)
|
||||||
|
_SOURCE_EXCEL_DIR = Path(
|
||||||
|
r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste"
|
||||||
|
)
|
||||||
|
_SOURCE_YEAR_PATTERN = re.compile(r"(20\d{2})")
|
||||||
|
_SOURCE_CHECK_INTERVAL_MS = 60_000
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_semver(version_str: str) -> Tuple[int, int, int]:
|
||||||
|
"""Parse semantic version string, ignoring build metadata after '+'."""
|
||||||
|
base = version_str.split("+")[0].strip()
|
||||||
|
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", base)
|
||||||
|
if not match:
|
||||||
|
return (0, 0, 0)
|
||||||
|
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||||
|
|
||||||
|
|
||||||
|
def _should_check_update() -> bool:
|
||||||
|
"""Return True if no check has been recorded for today."""
|
||||||
|
try:
|
||||||
|
if _UPDATE_CHECK_FILE.exists():
|
||||||
|
with open(_UPDATE_CHECK_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("last_check") != date.today().isoformat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _record_update_check() -> None:
|
||||||
|
"""Persist today's date as the last check timestamp."""
|
||||||
|
try:
|
||||||
|
with open(_UPDATE_CHECK_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"last_check": date.today().isoformat()}, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _find_latest_installer(config: Dict[str, Any]) -> Optional[Tuple[str, Path]]:
|
||||||
|
"""Scan the configured SMB update path for the newest installer.
|
||||||
|
|
||||||
|
Returns (version_str, installer_path) if a newer version exists, else None.
|
||||||
|
"""
|
||||||
|
update_path_str = str(config.get("update_check_path", "")).strip()
|
||||||
|
if not update_path_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_dir = Path(update_path_str)
|
||||||
|
if not update_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_ver = _parse_semver(str(config.get("version", "0.0.0")))
|
||||||
|
best_ver = current_ver
|
||||||
|
best_file: Optional[Path] = None
|
||||||
|
best_ver_str: Optional[str] = None
|
||||||
|
|
||||||
|
for f in update_dir.glob("AnwesenheitPDF-Setup-*.exe"):
|
||||||
|
m = _INSTALLER_PATTERN.match(f.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
ver_str = m.group(1)
|
||||||
|
ver = _parse_semver(ver_str)
|
||||||
|
if ver > best_ver:
|
||||||
|
best_ver = ver
|
||||||
|
best_file = f
|
||||||
|
best_ver_str = ver_str
|
||||||
|
|
||||||
|
if best_file and best_ver_str:
|
||||||
|
return (best_ver_str, best_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_from_excel_name(filename: str) -> Optional[int]:
|
||||||
|
"""Extract a four-digit year from an Excel filename."""
|
||||||
|
match = _SOURCE_YEAR_PATTERN.search(filename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_source_excel(files: List[Path]) -> Optional[Path]:
|
||||||
|
"""Pick the most relevant source Excel (current year preferred)."""
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_year = date.today().year
|
||||||
|
with_year: List[Tuple[Path, int]] = []
|
||||||
|
without_year: List[Path] = []
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
year = _extract_year_from_excel_name(file.name)
|
||||||
|
if year is None:
|
||||||
|
without_year.append(file)
|
||||||
|
else:
|
||||||
|
with_year.append((file, year))
|
||||||
|
|
||||||
|
current_year_files = [f for f, y in with_year if y == current_year]
|
||||||
|
if current_year_files:
|
||||||
|
return max(current_year_files, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
if with_year:
|
||||||
|
newest_year = max(y for _, y in with_year)
|
||||||
|
newest_year_files = [f for f, y in with_year if y == newest_year]
|
||||||
|
return max(newest_year_files, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
return max(without_year, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_source_excel_update() -> Dict[str, Any]:
|
||||||
|
"""Check source folder connectivity and whether a newer Excel is available."""
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"connected": False,
|
||||||
|
"update_available": False,
|
||||||
|
"source_file": None,
|
||||||
|
"target_file": None,
|
||||||
|
"status": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not _SOURCE_EXCEL_DIR.exists():
|
||||||
|
result["status"] = "Netzlaufwerk nicht erreichbar."
|
||||||
|
return result
|
||||||
|
|
||||||
|
source_files = sorted(_SOURCE_EXCEL_DIR.glob("*.xlsx"), key=lambda p: p.name.lower())
|
||||||
|
result["connected"] = True
|
||||||
|
|
||||||
|
if not source_files:
|
||||||
|
result["status"] = "Keine Excel-Datei in der Quelle gefunden."
|
||||||
|
return result
|
||||||
|
|
||||||
|
source_file = _choose_source_excel(source_files)
|
||||||
|
if source_file is None:
|
||||||
|
result["status"] = "Keine geeignete Excel-Datei in der Quelle gefunden."
|
||||||
|
return result
|
||||||
|
|
||||||
|
target_file = _input_dir() / source_file.name
|
||||||
|
result["source_file"] = source_file
|
||||||
|
result["target_file"] = target_file
|
||||||
|
|
||||||
|
if (not target_file.exists()) or (source_file.stat().st_mtime > target_file.stat().st_mtime):
|
||||||
|
result["update_available"] = True
|
||||||
|
result["status"] = f"Neue Excel verfügbar: {source_file.name}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["status"] = f"Excel aktuell: {source_file.name}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
result["connected"] = False
|
||||||
|
result["status"] = "Netzlaufwerk nicht erreichbar."
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_month_keys(config: Dict[str, Any]) -> List[str]:
|
def _build_month_keys(config: Dict[str, Any]) -> List[str]:
|
||||||
"""Return deduplicated month keys from config (preserves insertion order)."""
|
"""Return deduplicated month keys from config (preserves insertion order)."""
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
@@ -174,22 +337,81 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._out_dir: Optional[Path] = None
|
self._out_dir: Optional[Path] = None
|
||||||
self._input_files: List[Path] = []
|
self._input_files: List[Path] = []
|
||||||
self._excel_name_to_path: Dict[str, Path] = {}
|
self._excel_name_to_path: Dict[str, Path] = {}
|
||||||
|
self._pending_source_excel: Optional[Path] = None
|
||||||
self._tooltip_window: Optional[ctk.CTkToplevel] = None
|
self._tooltip_window: Optional[ctk.CTkToplevel] = None
|
||||||
self._tooltip_label: Optional[tk.Label] = None
|
self._tooltip_label: Optional[tk.Label] = None
|
||||||
|
|
||||||
# Build UI sections
|
# Build UI sections
|
||||||
|
self._build_update_banner()
|
||||||
self._build_input_section()
|
self._build_input_section()
|
||||||
self._build_button_section()
|
self._build_button_section()
|
||||||
self._build_preview_section()
|
self._build_preview_section()
|
||||||
self._build_log_section()
|
self._build_log_section()
|
||||||
|
|
||||||
# Auto-sync after event loop starts
|
# Initial scan + continuous source check
|
||||||
self.after(100, self._start_initial_sync)
|
self.after(100, self._start_source_excel_watch)
|
||||||
|
# Daily update check
|
||||||
|
if _should_check_update():
|
||||||
|
self.after(200, self._start_update_check)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# UI builders
|
# UI builders
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_update_banner(self) -> None:
|
||||||
|
"""Create the update-available banner (hidden until an update is found)."""
|
||||||
|
self._update_banner = ctk.CTkFrame(self, fg_color="#854D0E")
|
||||||
|
# Banner is NOT packed here — only shown when an update is detected
|
||||||
|
self._update_banner_label = ctk.CTkLabel(
|
||||||
|
self._update_banner,
|
||||||
|
text="",
|
||||||
|
text_color="#FEF9C3",
|
||||||
|
font=ctk.CTkFont(weight="bold"),
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
self._update_banner_label.pack(side="left", padx=12, pady=6, fill="x", expand=True)
|
||||||
|
self._update_banner_btn = ctk.CTkButton(
|
||||||
|
self._update_banner,
|
||||||
|
text="Installieren",
|
||||||
|
width=110,
|
||||||
|
height=28,
|
||||||
|
fg_color="#CA8A04",
|
||||||
|
hover_color="#A16207",
|
||||||
|
text_color="white",
|
||||||
|
command=self._open_update_folder,
|
||||||
|
)
|
||||||
|
self._update_banner_btn.pack(side="right", padx=(0, 12), pady=6)
|
||||||
|
self._update_installer_path: Optional[Path] = None
|
||||||
|
|
||||||
|
def _show_update_banner(self, version: str, installer: Path) -> None:
|
||||||
|
"""Display the update banner with the new version info."""
|
||||||
|
self._update_installer_path = installer
|
||||||
|
current = _CONFIG.get("version", "?").split("+")[0]
|
||||||
|
self._update_banner_label.configure(
|
||||||
|
text=f"Update verfügbar: v{current} → v{version} "
|
||||||
|
f"({installer.name})"
|
||||||
|
)
|
||||||
|
# Insert banner above everything else
|
||||||
|
self._update_banner.pack(fill="x", padx=16, pady=(8, 0), before=self.winfo_children()[1])
|
||||||
|
|
||||||
|
def _open_update_folder(self) -> None:
|
||||||
|
"""Open the installer directory in Explorer."""
|
||||||
|
if self._update_installer_path and self._update_installer_path.parent.exists():
|
||||||
|
os.startfile(str(self._update_installer_path.parent))
|
||||||
|
|
||||||
|
def _start_update_check(self) -> None:
|
||||||
|
threading.Thread(target=self._run_update_check, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_update_check(self) -> None:
|
||||||
|
try:
|
||||||
|
result = _find_latest_installer(_CONFIG)
|
||||||
|
except Exception:
|
||||||
|
result = None
|
||||||
|
_record_update_check()
|
||||||
|
if result:
|
||||||
|
version, installer = result
|
||||||
|
self.after(0, self._show_update_banner, version, installer)
|
||||||
|
|
||||||
def _build_input_section(self) -> None:
|
def _build_input_section(self) -> None:
|
||||||
frame = ctk.CTkFrame(self)
|
frame = ctk.CTkFrame(self)
|
||||||
frame.pack(fill="x", padx=16, pady=(16, 0))
|
frame.pack(fill="x", padx=16, pady=(16, 0))
|
||||||
@@ -216,6 +438,21 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
)
|
)
|
||||||
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w")
|
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
|
||||||
|
self._btn_excel_update = ctk.CTkButton(
|
||||||
|
frame,
|
||||||
|
text="Update",
|
||||||
|
width=90,
|
||||||
|
height=28,
|
||||||
|
command=self._on_excel_update_click,
|
||||||
|
)
|
||||||
|
self._btn_excel_update.grid(row=0, column=3, padx=(0, 6), pady=6, sticky="w")
|
||||||
|
self._btn_excel_update.grid_remove()
|
||||||
|
|
||||||
|
self._lbl_source_warning = ctk.CTkLabel(
|
||||||
|
frame, text="", width=24, anchor="w", text_color="orange"
|
||||||
|
)
|
||||||
|
self._lbl_source_warning.grid(row=0, column=4, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
|
||||||
# Row 1: Template
|
# Row 1: Template
|
||||||
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
||||||
row=1, column=0, padx=(12, 4), pady=6, sticky="w"
|
row=1, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
@@ -425,6 +662,32 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._excel_menu.configure(values=[text], state="disabled")
|
self._excel_menu.configure(values=[text], state="disabled")
|
||||||
self._excel_var.set(text)
|
self._excel_var.set(text)
|
||||||
|
|
||||||
|
def _set_source_state(
|
||||||
|
self,
|
||||||
|
connected: bool,
|
||||||
|
update_available: bool,
|
||||||
|
source_file: Optional[Path],
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
self._pending_source_excel = source_file if update_available else None
|
||||||
|
|
||||||
|
if not connected:
|
||||||
|
self._lbl_source_warning.configure(text="⚠")
|
||||||
|
self._btn_excel_update.grid_remove()
|
||||||
|
self._set_download_status(status or "Netzlaufwerk nicht erreichbar.", "orange")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._lbl_source_warning.configure(text="")
|
||||||
|
if update_available and source_file is not None:
|
||||||
|
self._btn_excel_update.grid()
|
||||||
|
self._btn_excel_update.configure(state="normal", text="Update")
|
||||||
|
self._set_download_status(status, "#60A5FA")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._btn_excel_update.grid_remove()
|
||||||
|
if status:
|
||||||
|
self._set_download_status(status, "#22C55E")
|
||||||
|
|
||||||
def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
|
def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
|
||||||
self._lbl_download_status.configure(text=text, text_color=color)
|
self._lbl_download_status.configure(text=text, text_color=color)
|
||||||
|
|
||||||
@@ -571,33 +834,78 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._excel_var.set(selected_name)
|
self._excel_var.set(selected_name)
|
||||||
self._excel_path = self._excel_name_to_path[selected_name]
|
self._excel_path = self._excel_name_to_path[selected_name]
|
||||||
|
|
||||||
def _start_initial_sync(self) -> None:
|
def _start_source_excel_watch(self) -> None:
|
||||||
self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...")
|
self._scan_and_validate()
|
||||||
self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...")
|
self._start_source_excel_check()
|
||||||
threading.Thread(target=self._run_initial_sync, daemon=True).start()
|
|
||||||
|
|
||||||
def _run_initial_sync(self) -> None:
|
def _start_source_excel_check(self) -> None:
|
||||||
|
threading.Thread(target=self._run_source_excel_check, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_source_excel_check(self) -> None:
|
||||||
|
result = _check_source_excel_update()
|
||||||
|
self.after(0, self._on_source_excel_check_done, result)
|
||||||
|
|
||||||
|
def _on_source_excel_check_done(self, result: Dict[str, Any]) -> None:
|
||||||
|
self._set_source_state(
|
||||||
|
connected=bool(result.get("connected")),
|
||||||
|
update_available=bool(result.get("update_available")),
|
||||||
|
source_file=result.get("source_file"),
|
||||||
|
status=str(result.get("status") or ""),
|
||||||
|
)
|
||||||
|
self.after(_SOURCE_CHECK_INTERVAL_MS, self._start_source_excel_check)
|
||||||
|
|
||||||
|
def _on_excel_update_click(self) -> None:
|
||||||
|
source_file = self._pending_source_excel
|
||||||
|
if source_file is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_file = _input_dir() / source_file.name
|
||||||
|
if target_file.exists():
|
||||||
|
proceed = messagebox.askokcancel(
|
||||||
|
"Datei überschreiben",
|
||||||
|
f"Die vorhandene Datei\n{target_file.name}\n"
|
||||||
|
"wird durch die neuere Datei ersetzt.\n\nFortfahren?",
|
||||||
|
icon="warning",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
if not proceed:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._btn_excel_update.configure(state="disabled", text="Lade...")
|
||||||
|
self._set_download_status(f"Lade {source_file.name} ...", "#60A5FA")
|
||||||
|
threading.Thread(
|
||||||
|
target=self._run_source_excel_download,
|
||||||
|
args=(source_file,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _run_source_excel_download(self, source_file: Path) -> None:
|
||||||
try:
|
try:
|
||||||
synced_path, status = _sync_network_excel(_CONFIG)
|
target_file = _copy_excel_to_input(source_file)
|
||||||
self.after(0, self._finish_initial_sync, synced_path, status, None)
|
self.after(0, self._on_source_excel_download_done, target_file, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.after(0, self._finish_initial_sync, None, "", str(exc))
|
self.after(0, self._on_source_excel_download_done, None, str(exc))
|
||||||
|
|
||||||
def _finish_initial_sync(
|
def _on_source_excel_download_done(
|
||||||
self,
|
self,
|
||||||
synced_path: Optional[Path],
|
target_file: Optional[Path],
|
||||||
status: str,
|
|
||||||
error: Optional[str],
|
error: Optional[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self._btn_excel_update.configure(state="normal", text="Update")
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
self._set_download_status(
|
self._set_download_status(f"Update fehlgeschlagen: {error}", "red")
|
||||||
f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}",
|
return
|
||||||
"orange",
|
|
||||||
)
|
if target_file is None:
|
||||||
else:
|
self._set_download_status("Update fehlgeschlagen.", "red")
|
||||||
color = "#22C55E" if synced_path else "orange"
|
return
|
||||||
self._set_download_status(status, color)
|
|
||||||
self._scan_and_validate(selected_excel=synced_path)
|
self._set_download_status(f"Excel aktualisiert: {target_file.name}", "#22C55E")
|
||||||
|
self._refresh_excel_dropdown(selected=target_file)
|
||||||
|
self._on_excel_changed()
|
||||||
|
self._pending_source_excel = None
|
||||||
|
self._btn_excel_update.grid_remove()
|
||||||
|
|
||||||
def _on_excel_changed(self) -> None:
|
def _on_excel_changed(self) -> None:
|
||||||
selected = self._excel_name_to_path.get(self._excel_var.get())
|
selected = self._excel_name_to_path.get(self._excel_var.get())
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
; Inno Setup Script fuer AnwesenheitPDF
|
||||||
|
; Compiler: Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
|
||||||
|
;
|
||||||
|
; Aufruf durch build.bat:
|
||||||
|
; iscc.exe /DMyAppVersion=1.0.1 installer\AnwesenheitPDF.iss
|
||||||
|
;
|
||||||
|
; Ausgabe: dist\AnwesenheitPDF-Setup-{version}.exe
|
||||||
|
|
||||||
|
#ifndef MyAppVersion
|
||||||
|
#define MyAppVersion "0.0.0"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define MyAppName "AnwesenheitPDF"
|
||||||
|
#define MyAppPublisher "SAB Anwesenheitslisten"
|
||||||
|
#define MyAppExeName "AnwesenheitPDF.exe"
|
||||||
|
#define MyAppID "{{A3F2C1D7-84BE-4E9A-B5D0-2C7F3A1E9B46}"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; Eindeutige App-ID – niemals aendern (sonst gelten Updates als Neuinstallation)
|
||||||
|
AppId={#MyAppID}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppVerName={#MyAppName} {#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
AppComments=Anwesenheitslisten aus Excel-Daten als PDF generieren
|
||||||
|
|
||||||
|
; Installations-Zielverzeichnis (Programme-Ordner)
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DefaultGroupName={#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
|
||||||
|
; Installer-Ausgabe
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=AnwesenheitPDF-Setup-{#MyAppVersion}
|
||||||
|
|
||||||
|
; Komprimierung
|
||||||
|
Compression=lzma2/ultra64
|
||||||
|
SolidCompression=yes
|
||||||
|
LZMAUseSeparateProcess=yes
|
||||||
|
|
||||||
|
; Erscheinungsbild
|
||||||
|
WizardStyle=modern
|
||||||
|
SetupIconFile=..\assets\icon.ico
|
||||||
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
|
|
||||||
|
; Berechtigungen: Admin erforderlich fuer Installation in Programme-Ordner
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
|
||||||
|
; Mindest-Windows-Version: Windows 10 (10.0)
|
||||||
|
MinVersion=10.0
|
||||||
|
|
||||||
|
; Installer kann direkt vom Netzlaufwerk (SMB-Share) ausgefuehrt werden
|
||||||
|
; (kein lokales Entpacken des Installers noetig)
|
||||||
|
AllowNetworkDrive=yes
|
||||||
|
AllowUNCPath=yes
|
||||||
|
|
||||||
|
; Versionsinformation fuer die EXE-Eigenschaften
|
||||||
|
VersionInfoVersion={#MyAppVersion}
|
||||||
|
VersionInfoCompany={#MyAppPublisher}
|
||||||
|
VersionInfoDescription={#MyAppName} Setup
|
||||||
|
VersionInfoProductName={#MyAppName}
|
||||||
|
VersionInfoProductVersion={#MyAppVersion}
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "startmenuicon"; \
|
||||||
|
Description: "Startmenü-Einträge erstellen"; \
|
||||||
|
GroupDescription: "Startmenü:"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
; Gesamten PyInstaller-Output in das Installationsverzeichnis kopieren
|
||||||
|
Source: "..\dist\AnwesenheitPDF\*"; \
|
||||||
|
DestDir: "{app}"; \
|
||||||
|
Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
; Startmenü-Eintrag (nur wenn Task ausgewaehlt)
|
||||||
|
Name: "{group}\{#MyAppName}"; \
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; \
|
||||||
|
Comment: "Anwesenheitslisten als PDF generieren"; \
|
||||||
|
Tasks: startmenuicon
|
||||||
|
|
||||||
|
; Startmenü: Uninstaller (nur wenn Task ausgewaehlt)
|
||||||
|
Name: "{group}\{#MyAppName} deinstallieren"; \
|
||||||
|
Filename: "{uninstallexe}"; \
|
||||||
|
Tasks: startmenuicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
; Optionaler Start nach der Installation
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; \
|
||||||
|
Description: "{#MyAppName} jetzt starten"; \
|
||||||
|
Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[UninstallDelete]
|
||||||
|
; Beim Deinstallieren: nur generierte Dateien loeschen, keine Nutzerdaten
|
||||||
|
Type: files; Name: "{app}\*.log"
|
||||||
+20
@@ -97,9 +97,29 @@
|
|||||||
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
|
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"regression": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Einstellungen fuer den Regressions-Bildvergleich (pytest).",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Aktiviert oder deaktiviert den Regressions-Bildvergleich. false = Tests werden uebersprungen (default).",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"mask_footer": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Ignoriert den unteren Bereich (Footer) beim Pixel-Vergleich. true = untere 20% maskieren; false = vollstaendiger Vergleich.",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"excel_network_source": {
|
"excel_network_source": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird"
|
"description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird"
|
||||||
|
},
|
||||||
|
"update_check_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SMB-Pfad zum Verzeichnis mit Installer-Dateien (AnwesenheitPDF-Setup-x.y.z.exe) fuer automatische Update-Pruefung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,30 @@ def get_sheet_names(file_path: str | Path) -> List[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sheet_name(
|
||||||
|
sheet_name: str,
|
||||||
|
workbook_sheets: List[str],
|
||||||
|
month_mapping: Dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
"""Resolve a month alias to the actual sheet name present in the workbook.
|
||||||
|
|
||||||
|
If *sheet_name* exists in *workbook_sheets*, it is returned unchanged.
|
||||||
|
Otherwise all aliases in *month_mapping* that map to the same month number
|
||||||
|
are checked, and the first one present in *workbook_sheets* is returned.
|
||||||
|
If no alias matches, the original *sheet_name* is returned so that the
|
||||||
|
normal missing-sheet error can fire.
|
||||||
|
"""
|
||||||
|
if sheet_name in workbook_sheets:
|
||||||
|
return sheet_name
|
||||||
|
target_month = month_mapping.get(sheet_name)
|
||||||
|
if target_month is None:
|
||||||
|
return sheet_name
|
||||||
|
for alias, month_num in month_mapping.items():
|
||||||
|
if month_num == target_month and alias in workbook_sheets:
|
||||||
|
return alias
|
||||||
|
return sheet_name
|
||||||
|
|
||||||
|
|
||||||
def extract_persons(
|
def extract_persons(
|
||||||
file_path: str | Path,
|
file_path: str | Path,
|
||||||
sheet_name: str,
|
sheet_name: str,
|
||||||
@@ -158,6 +182,7 @@ def extract_persons(
|
|||||||
|
|
||||||
wb = _open_workbook(Path(file_path))
|
wb = _open_workbook(Path(file_path))
|
||||||
try:
|
try:
|
||||||
|
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
|
||||||
data = _sheet_data(wb, sheet_name)
|
data = _sheet_data(wb, sheet_name)
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
@@ -217,6 +242,7 @@ def extract_attendance(
|
|||||||
|
|
||||||
wb = _open_workbook(Path(file_path))
|
wb = _open_workbook(Path(file_path))
|
||||||
try:
|
try:
|
||||||
|
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, month_mapping)
|
||||||
data = _sheet_data(wb, sheet_name)
|
data = _sheet_data(wb, sheet_name)
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|||||||
+11
-1
@@ -59,6 +59,8 @@ def build_field_dict(
|
|||||||
datum_field: creation_date.strftime("%d.%m.%Y"),
|
datum_field: creation_date.strftime("%d.%m.%Y"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internship_hours = f"{plan_threshold:.2f}".replace(".", ",")
|
||||||
|
qualifying_attendance_count: int = 0
|
||||||
pauschale_5_count: int = 0
|
pauschale_5_count: int = 0
|
||||||
|
|
||||||
for idx, entry in enumerate(attendance.entries):
|
for idx, entry in enumerate(attendance.entries):
|
||||||
@@ -68,12 +70,20 @@ def build_field_dict(
|
|||||||
if entry.is_present:
|
if entry.is_present:
|
||||||
fields[f"ist.{idx}"] = entry.hours_german()
|
fields[f"ist.{idx}"] = entry.hours_german()
|
||||||
fields[f"plan.{idx}"] = plan_value
|
fields[f"plan.{idx}"] = plan_value
|
||||||
|
if entry.hours is not None and entry.hours >= plan_threshold:
|
||||||
|
qualifying_attendance_count += 1
|
||||||
# Pauschale only if actual hours >= planned threshold
|
# Pauschale only if actual hours >= planned threshold
|
||||||
if entry.hours is not None and entry.hours >= plan_threshold:
|
if entry.hours is not None and entry.hours >= plan_threshold:
|
||||||
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
|
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
|
||||||
pauschale_5_count += 1
|
pauschale_5_count += 1
|
||||||
else:
|
else:
|
||||||
fields[f"{pauschale_prefix}.{idx}"] = "0"
|
fields[f"{pauschale_prefix}.{idx}"] = "0"
|
||||||
|
elif entry.status == "P":
|
||||||
|
fields[f"ist.{idx}"] = internship_hours
|
||||||
|
fields[f"plan.{idx}"] = plan_value
|
||||||
|
qualifying_attendance_count += 1
|
||||||
|
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
|
||||||
|
pauschale_5_count += 1
|
||||||
else:
|
else:
|
||||||
fields[f"ist.{idx}"] = ""
|
fields[f"ist.{idx}"] = ""
|
||||||
fields[f"plan.{idx}"] = ""
|
fields[f"plan.{idx}"] = ""
|
||||||
@@ -86,7 +96,7 @@ def build_field_dict(
|
|||||||
fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}"
|
fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}"
|
||||||
fields["pauschale_25_sum"] = "0.00"
|
fields["pauschale_25_sum"] = "0.00"
|
||||||
fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}"
|
fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}"
|
||||||
fields["anwesend_sum"] = str(pauschale_5_count)
|
fields["anwesend_sum"] = str(qualifying_attendance_count)
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .config_loader import load_config
|
from .config_loader import load_config
|
||||||
|
from .excel_reader import resolve_sheet_name
|
||||||
from .exceptions import ConfigurationError
|
from .exceptions import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
@@ -71,18 +72,19 @@ def validate(
|
|||||||
)
|
)
|
||||||
sheet_names = wb.sheetnames
|
sheet_names = wb.sheetnames
|
||||||
|
|
||||||
# Check 4: sheet name valid
|
# Check 4: sheet name valid (resolve alias, e.g. Mär → Mrz)
|
||||||
if sheet_name not in config["month_mapping"]:
|
if sheet_name not in config["month_mapping"]:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json."
|
f"Sheet-Name '{sheet_name}' nicht in month_mapping der config.json."
|
||||||
)
|
)
|
||||||
if sheet_name not in sheet_names:
|
resolved = resolve_sheet_name(sheet_name, sheet_names, config["month_mapping"])
|
||||||
|
if resolved not in sheet_names:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
|
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
|
||||||
f"Verfügbar: {sheet_names}"
|
f"Verfügbar: {sheet_names}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ws = wb[sheet_name]
|
ws = wb[resolved]
|
||||||
rows = list(ws.iter_rows(values_only=True))
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
|
||||||
name_col: int = config["excel_name_column"]
|
name_col: int = config["excel_name_column"]
|
||||||
@@ -179,11 +181,12 @@ def _extract_samples(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
wb = openpyxl.load_workbook(str(file_path), read_only=True, data_only=True)
|
wb = openpyxl.load_workbook(str(file_path), read_only=True, data_only=True)
|
||||||
if sheet_name not in wb.sheetnames:
|
resolved = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
|
||||||
|
if resolved not in wb.sheetnames:
|
||||||
wb.close()
|
wb.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
ws = wb[sheet_name]
|
ws = wb[resolved]
|
||||||
rows = list(ws.iter_rows(values_only=True))
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
|
|||||||
@@ -128,21 +128,30 @@ class TestAbsentDay:
|
|||||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||||
assert fields["anwesend.0"] == status
|
assert fields["anwesend.0"] == status
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
|
||||||
def test_ist_empty(self, cfg, status):
|
def test_ist_empty(self, cfg, status):
|
||||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||||
assert fields["ist.0"] == ""
|
assert fields["ist.0"] == ""
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
|
||||||
def test_plan_empty(self, cfg, status):
|
def test_plan_empty(self, cfg, status):
|
||||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||||
assert fields["plan.0"] == ""
|
assert fields["plan.0"] == ""
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "P", "U"])
|
@pytest.mark.parametrize("status", ["K", "KK", "E", "F", "U"])
|
||||||
def test_pauschale_empty(self, cfg, status):
|
def test_pauschale_empty(self, cfg, status):
|
||||||
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
|
||||||
assert fields["pauschale_5.0"] == "0"
|
assert fields["pauschale_5.0"] == "0"
|
||||||
|
|
||||||
|
def test_praktikum_sets_fixed_hours(self, cfg):
|
||||||
|
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
|
||||||
|
assert fields["ist.0"] == "6,00"
|
||||||
|
assert fields["plan.0"] == "6"
|
||||||
|
|
||||||
|
def test_praktikum_sets_pauschale(self, cfg):
|
||||||
|
fields = build_field_dict(_make_attendance(_absent(4, "P")), cfg, date(2026, 2, 1))
|
||||||
|
assert fields["pauschale_5.0"] == "1"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Multi-row indexing
|
# Multi-row indexing
|
||||||
@@ -177,6 +186,17 @@ class TestSummaryFields:
|
|||||||
# Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not
|
# Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not
|
||||||
assert fields["anwesend_sum"] == "2"
|
assert fields["anwesend_sum"] == "2"
|
||||||
|
|
||||||
|
def test_anwesend_sum_counts_praktikum_as_6h(self, cfg):
|
||||||
|
entries = [_present(1, 6.0), _absent(2, "P"), _present(3, 5.0)]
|
||||||
|
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
|
||||||
|
assert fields["anwesend_sum"] == "2"
|
||||||
|
|
||||||
|
def test_praktikum_increases_pauschale_sum(self, cfg):
|
||||||
|
entries = [_absent(1, "P")]
|
||||||
|
fields = build_field_dict(_make_attendance(*entries), cfg, date(2026, 2, 1))
|
||||||
|
assert fields["pauschale_5_sum"] == "8.60"
|
||||||
|
assert fields["pauschale_sum"] == "8.60"
|
||||||
|
|
||||||
def test_pauschale_5_sum_euro(self, cfg):
|
def test_pauschale_5_sum_euro(self, cfg):
|
||||||
"""pauschale_5_sum = qualifying days * 8.60 (period decimal for JS)."""
|
"""pauschale_5_sum = qualifying days * 8.60 (period decimal for JS)."""
|
||||||
entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)]
|
entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ When a page fails the threshold, three images are saved to
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -131,26 +132,46 @@ def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
|
|||||||
writer.write(fh)
|
writer.write(fh)
|
||||||
|
|
||||||
|
|
||||||
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float:
|
def _compute_similarity(ref: np.ndarray, gen: np.ndarray, mask_footer: bool = False) -> tuple[float, int, int]:
|
||||||
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE."""
|
"""Return (similarity, different_pixels, total_pixels).
|
||||||
|
|
||||||
|
If mask_footer=True, ignores the bottom ~20% of each image (footer area).
|
||||||
|
"""
|
||||||
if ref.shape != gen.shape:
|
if ref.shape != gen.shape:
|
||||||
gen = _resize_to_match(gen, ref.shape)
|
gen = _resize_to_match(gen, ref.shape)
|
||||||
|
|
||||||
|
# Apply footer mask if requested
|
||||||
|
if mask_footer:
|
||||||
|
h = ref.shape[0]
|
||||||
|
mask_start = int(h * 0.80) # Ignore bottom 20%
|
||||||
|
ref = ref[:mask_start]
|
||||||
|
gen = gen[:mask_start]
|
||||||
|
|
||||||
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
||||||
matching = np.all(diff <= PIXEL_TOLERANCE, axis=2)
|
matching = np.all(diff <= PIXEL_TOLERANCE, axis=2)
|
||||||
return float(matching.mean())
|
similar_count = np.sum(matching)
|
||||||
|
total_count = matching.size
|
||||||
|
|
||||||
|
return (similar_count / total_count, total_count - similar_count, total_count)
|
||||||
|
|
||||||
|
|
||||||
def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str) -> None:
|
def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_label: str, mask_footer: bool = False) -> None:
|
||||||
"""Save reference, generated, and heatmap diff images for a failing page."""
|
"""Save reference, generated, and heatmap diff images for a failing page.
|
||||||
|
|
||||||
|
If mask_footer=True, the bottom ~20% (footer area) is shown in gray in the diff image.
|
||||||
|
The diff is computed BEFORE masking to show actual differences, then the masked area is grayed out.
|
||||||
|
"""
|
||||||
if ref.shape != gen.shape:
|
if ref.shape != gen.shape:
|
||||||
gen = _resize_to_match(gen, ref.shape)
|
gen = _resize_to_match(gen, ref.shape)
|
||||||
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save full reference and generated images
|
||||||
Image.fromarray(ref).save(str(out_dir / f"{page_label}_reference.png"))
|
Image.fromarray(ref).save(str(out_dir / f"{page_label}_reference.png"))
|
||||||
Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png"))
|
Image.fromarray(gen).save(str(out_dir / f"{page_label}_generated.png"))
|
||||||
|
|
||||||
# Heatmap: identical areas dimmed, differences highlighted in red
|
# Heatmap: identical areas dimmed, differences highlighted in red
|
||||||
|
# NOTE: diff is computed on FULL images, not masked, to show all actual differences
|
||||||
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
diff = np.abs(ref.astype(np.int32) - gen.astype(np.int32))
|
||||||
max_diff_per_pixel = diff.max(axis=2)
|
max_diff_per_pixel = diff.max(axis=2)
|
||||||
severity = max_diff_per_pixel / 255.0
|
severity = max_diff_per_pixel / 255.0
|
||||||
@@ -162,6 +183,12 @@ def _save_diff_images(ref: np.ndarray, gen: np.ndarray, out_dir: Path, page_labe
|
|||||||
blended = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8)
|
blended = (base * (1.0 - alpha) + overlay * alpha).astype(np.uint8)
|
||||||
blended[max_diff_per_pixel > 30] = [255, 0, 0]
|
blended[max_diff_per_pixel > 30] = [255, 0, 0]
|
||||||
|
|
||||||
|
# Gray out masked footer region in diff image
|
||||||
|
if mask_footer:
|
||||||
|
h = ref.shape[0]
|
||||||
|
mask_start = int(h * 0.99)
|
||||||
|
blended[mask_start:] = (180, 180, 180) # Gray footer region
|
||||||
|
|
||||||
Image.fromarray(blended).save(str(out_dir / f"{page_label}_diff.png"))
|
Image.fromarray(blended).save(str(out_dir / f"{page_label}_diff.png"))
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +208,17 @@ def test_pdf_matches_reference(
|
|||||||
|
|
||||||
project_root = Path(__file__).parent.parent
|
project_root = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config_path = project_root / "config.json"
|
||||||
|
with config_path.open() as f:
|
||||||
|
config = json.load(f)
|
||||||
|
regression_cfg = config.get("regression", {})
|
||||||
|
|
||||||
|
if not regression_cfg.get("enabled", False):
|
||||||
|
pytest.skip("Regression-Bildvergleich deaktiviert (regression.enabled=false in config.json)")
|
||||||
|
|
||||||
|
mask_footer = regression_cfg.get("mask_footer", True)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 1. Generate PDFs via the CLI into a temporary directory
|
# 1. Generate PDFs via the CLI into a temporary directory
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -252,18 +290,19 @@ def test_pdf_matches_reference(
|
|||||||
ref_img = ref_images[page_idx]
|
ref_img = ref_images[page_idx]
|
||||||
gen_img = gen_images[page_idx]
|
gen_img = gen_images[page_idx]
|
||||||
|
|
||||||
similarity = _compute_similarity(ref_img, gen_img)
|
similarity, diff_pixels, total_pixels = _compute_similarity(ref_img, gen_img, mask_footer=mask_footer)
|
||||||
|
|
||||||
if similarity < SIMILARITY_THRESHOLD:
|
if similarity < SIMILARITY_THRESHOLD:
|
||||||
page_tag = f"seite_{page_idx + 1:02d}"
|
page_tag = f"seite_{page_idx + 1:02d}"
|
||||||
img_dir = diff_dir / clean_stem / page_tag
|
img_dir = diff_dir / clean_stem / page_tag
|
||||||
_save_diff_images(
|
_save_diff_images(
|
||||||
ref_img, gen_img, img_dir, page_tag
|
ref_img, gen_img, img_dir, page_tag, mask_footer=mask_footer
|
||||||
)
|
)
|
||||||
rel = img_dir.relative_to(project_root)
|
rel = img_dir.relative_to(project_root)
|
||||||
failures.append(
|
failures.append(
|
||||||
f" {page_label}: Ähnlichkeit {similarity:.1%} "
|
f" {page_label}: Ähnlichkeit {similarity:.2%} "
|
||||||
f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
|
f"({diff_pixels:,} / {total_pixels:,} Pixel unterschiedlich, "
|
||||||
|
f"Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
|
||||||
)
|
)
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
|
|||||||
Reference in new issue
Block a user