Compare commits

..
18 Commits
Author SHA1 Message Date
Rob 0163f21e80 version 1.0.17 2026-05-21 23:51:38 +02:00
Rob 2eef2c1052 add: schau regelmäßig nach neuerem Excel file auf Netzlaufwerk oder zeige Warnung wenn nicht erreichbar 2026-05-21 23:25:47 +02:00
Rob 7d82aeb0f5 fix: Praktikum mit 6h wird wie ganz normaler Tag gewertet und mit Pauschale versehen 2026-05-21 23:24:22 +02:00
Rob 0d5620ae89 Installer auf Netzlaufwerk deployen 2026-05-19 15:39:47 +02:00
Rob 8efadb3c79 update: update path 2026-05-19 15:38:29 +02:00
Rob 7c629f189a fix changed month sheet name (Mär -> Mrz) 2026-05-19 15:34:17 +02:00
Rob d07b4832c6 Erweitere Regression Test (Bildvergleich) aber deaktiviere ihn default, weil das diff noch zu groß ist. 2026-05-08 14:13:14 +02:00
Rob 1fb6444444 add: Installer funktionen mit auto Update check 2026-05-08 14:12:03 +02:00
Rob 102b485794 add: Versionierung
- semantic versioning
- auto update on build
- git hash zusätzlich
- Anzeige per cli und im Fenster
2026-05-08 08:35:50 +02:00
Rob 250a473253 build: create template and output folder; auto start exe 2026-04-28 22:20:29 +02:00
Rob 9d0390f8fd Multiple changes
- Excel from network folder
- multiple input Excel via dowpdown,
- free creation date,
- Output folder path tooltip
2026-04-28 22:17:11 +02:00
Rob 6e9d865a9d add GUI with customtkinter and build to windows executable 2026-04-17 17:11:45 +02:00
Rob 34e9aa49d6 feat(cli): add shared PDF-to-image rendering and document usage
add shared render_pdf_pages helper for consistent rasterization
reuse the same renderer in regression test and CLI flow
add new render-image CLI command to export PDF pages as PNG
support single-page render (--page) and full export (--all-pages)
add configurable output path and DPI options
document the new rendering command and options in README
2026-04-17 13:48:27 +02:00
Rob 34fe877bb2 regression test: Javascript buttons maskieren beim visuellen vergleich
Neue konstante Maskenrechtecke in PDF-Punktkoordinaten für die beiden Buttons unten rechts.
Neue Hilfsfunktion _mask_ignored_regions(...), die diese Bereiche auf Schwarz setzt.
Anwendung der Maske auf Seite 1 vor der Similarity-Berechnung.
Anwendung derselben maskierten Bilder auch für gespeicherte Diff-Bilder.
2026-04-17 08:14:12 +02:00
Rob bf89d116f2 fix: also set font to black in Summenfeld für min. 3h Anwesenheit 2026-04-16 18:06:31 +02:00
Rob 2433478680 In PDF Javascript gespeicherte Feldoptionen verwenden
Die Kosten-pro-Tag-Berechnung und die Drop-Down-Optionen im Anwesenheitsfeld werden per Javsacript gemacht. JS funktioniert aber in pyumpdf nicht, darum werden die korrekten Optionen aus dem JS ausgelesen und dieses 1:1 in den Feldern gesetzt. Mittels Schalter in der config können auch wieder die Rohwerte aus dem Excel gesetzt werden. (PDF muss dann von Hand geöffnet und gespeichert werden damit die entgültigen passenden Werte enthalten sind).
2026-04-16 17:04:11 +02:00
Rob c51eddcb73 PDF ausfüllen: Textfarbe in den Summenfeldern unter Tabelle von weiß auf schwarz geändert 2026-04-16 14:27:48 +02:00
Rob bf671a9acd AI: update main instructions and add Caveman skill to save tokens. 2026-04-16 13:54:51 +02:00
24 changed files with 2242 additions and 57 deletions

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# Copilot Plan: Windows GUI für Anwesenheit PDF Generator
## Ziel
Eine Windows-GUI mit customtkinter als zweiter Entry-Point (`gui.py`) neben dem
bestehenden CLI (`generate.py`). Die GUI wrappt direkt die vorhandenen `src/`-Module
und verändert keine Business Logic.
## Architektur
```
gui.py # GUI Entry-Point (customtkinter)
scripts/
create_icon.py # Einmaliges Script: erzeugt assets/icon.ico mit Pillow
assets/
icon.ico # Generiertes Icon (committed)
```
Bestehende Dateien die erweitert werden:
```
requirements.txt # + customtkinter>=5.2.0
requirements-dev.txt # + pyinstaller>=6.0.0
.gitignore # + build/, AnwesenheitPDF.spec, AnwesenheitPDF.exe
```
Nicht anzufassen:
```
generate.py, src/*.py, tests/, config.json, schema.json
```
## Datenfluss
```
GUI-Eingabe (Datei, Monat)
→ der Ordner Input wird gescannt, befindet sich eine Exceldatei darin, wird diese als input verwendet
→ der Ordner template wird gescannt und eine darin befindliche pdf als template datei verwendet
→ validate() [src/validators.py]
→ Vorschau-Panel aktualisieren
→ Default wird der Ordner /output als zeil für die generierten PDFs verwendet.
→ Sind schon Dateien in output ist eine Hinweis in der GUI zu sehen und vor dem Start der generierung kommt ein Hinweis (im Hinweis kann der Inhalt des Ordners gelöscht werden)
→ für eine gewählte Exceldatei + Templatedatei werden automatisch die Preview zeilen angezeigt so wie in show_preview_and_confirm()
GUI-Bestätigung (Generieren)
→ extract_persons() [src/excel_reader.py]
→ extract_year_from_filename() [src/excel_reader.py]
→ für jede Person:
extract_attendance() [src/excel_reader.py]
fill_pdf() [src/pdf_filler.py]
→ Log-Panel: [OK] / [FEHLER] pro Person
```
## Entwurfsentscheidungen
- Framework: **customtkinter** (modern, leichtgewichtig, Windows-nativ wirkend)
- CLI (`generate.py`) bleibt vollständig unverändert
- `config.json` bleibt manuell bearbeitbar — kein Settings-Tab in der GUI
- `show_preview_and_confirm()` aus `validators.py` wird **nicht** genutzt (CLI-only)
- `_resource_path()` Hilfsfunktion für PyInstaller-Kompatibilität (`sys._MEIPASS`)
- Validierung + Generierung laufen in `threading.Thread` (GUI bleibt responsiv)
- GUI-Updates aus Threads nur via `self.after(0, callback)` (thread-safe)
- `.exe` landet direkt im Root-Verzeichnis (kein `dist/`-Unterordner)
- Icon: Pillow-generierter Placeholder (`assets/icon.ico`)
## Packaging (kein Installer)
Build-Kommando (einmalig, nach Abschluss der Implementierung):
```
pyinstaller --onefile --windowed --distpath . --icon assets/icon.ico ^
--add-data "template;template" --add-data "config.json;." ^
--add-data "schema.json;." --add-data "assets;assets" ^
--name "AnwesenheitPDF" gui.py
```
Ergebnis: `AnwesenheitPDF.exe` nicht direkt im Projekt-Root, sondern mit allem was zum build der Exe benötigt wird im ordner /build und alles was zum ausführen der Exebenötigt wird im Ordner /dist.
+39
View File
@@ -61,3 +61,42 @@ Excel-Datei → excel_reader → MonthlyAttendance → pdf_filler → PDF-Datei
| Numerisch | DD.MM.YYYY | X | Wert (dt. Format) | "6" | "1" |
| "K"/"KK"/etc| DD.MM.YYYY | Code | leer | leer | "0" |
| 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.
@@ -0,0 +1,48 @@
---
applyTo: '**'
---
<!-- skill: caveman | description: Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, wenyan-lite, wenyan-full, wenyan-ultra. Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active
if unsure. Off only: "stop caveman" / "normal mode".
Default: full. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply),
pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short
synonyms (big not extensive, fix not "implement a solution for"). Technical terms
exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is
likely caused by..." Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | Behavior |
|-------|----------|
| lite | No filler/hedging. Keep articles + full sentences. Professional but tight |
| full | Drop articles, fragments OK, short synonyms. Classic caveman |
| ultra | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| wenyan-lite | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| wenyan-full | Maximum classical terseness. Fully 文言文. 80-90% character reduction |
| wenyan-ultra | Extreme abbreviation while keeping classical Chinese feel. Maximum compression |
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations,
multi-step sequences where fragment order risks misread, user asks to clarify or repeats
question. Resume caveman after clear part done.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level
persist until changed or session end.
@@ -24,3 +24,7 @@ Write down your plan into the planning file at: .github\copilot-plan.md
If the user or I do ask a question, you think about the question and if nessesary ask importand questions back to clarify the context. Than try to find information about the aked question and additional informations.
If I ask a question, don't implement some solution directly, but ask me what to do and ask questions back to clarify the context if nessesary.
# Commit changes in git
Git commits must be always atomic and be written in plain english.
+6
View File
@@ -3,8 +3,14 @@ earlier_versions/
output
__pycache__
# PyInstaller build artifacts
build/
dist/
AnwesenheitPDF.spec
# Fixture-Testdaten (personenbezogene Daten — nicht versioniert)
tests/fixtures/**/*.xlsx
tests/fixtures/**/*.xlsm
tests/fixtures/**/reference/*.pdf
tests/fixtures/**/diff/
*.log
+135
View File
@@ -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.
### 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
@@ -86,6 +139,20 @@ python generate.py validate --file "input/Anwesenheitsliste 2026.xlsx" --month F
Führt alle Prüfungen durch und zeigt die Vorschau — ohne Dateien zu schreiben.
### PDF als Bild rendern
```bash
python generate.py render-image --pdf "output/(1) Mustermann, Max.pdf" --page 1 --out "preview.png"
```
Dieser Befehl rendert eine PDF-Seite als PNG-Bild. Er nutzt dieselbe Render-Funktion wie der Regressionstest, damit CLI-Vorschau und Testvergleich technisch identisch arbeiten.
Alle Seiten auf einmal rendern:
```bash
python generate.py render-image --pdf "output/(1) Mustermann, Max.pdf" --all-pages --out "render_out"
```
---
## Optionen
@@ -105,6 +172,16 @@ Führt alle Prüfungen durch und zeigt die Vorschau — ohne Dateien zu schreibe
| `--file` | ja | Pfad zur Excel-Datei |
| `--month` | ja | Name des Monats-Sheets |
### `render-image`
| Option | Pflicht | Beschreibung |
|---|---|---|
| `--pdf` | ja | Pfad zur PDF-Datei |
| `--out` | nein | Zieldatei (Einzelseite) oder Zielordner (`--all-pages`) |
| `--dpi` | nein | Render-Auflösung in DPI (Standard: `150`) |
| `--page` | nein | 1-basierte Seitennummer (Standard: `1`) |
| `--all-pages` | nein | Rendert alle Seiten statt nur `--page` |
---
## Sheet-Namen
@@ -126,6 +203,19 @@ Die deutschen Monatsabkürzungen entsprechen den Tabellenblatt-Namen in der Exce
| `Nov` | November |
| `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
@@ -174,3 +264,48 @@ Das Programm prüft vor jeder Generierung:
7. Headerzeile mit `Name, Vorname` gefunden
8. Tagesspalten ab Spaltenindex 9 vorhanden
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 |
+13
View File
@@ -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`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

+136
View File
@@ -0,0 +1,136 @@
@echo off
REM Build-Script fuer AnwesenheitPDF.exe + Windows-Installer
REM
REM Ergebnis:
REM build/ - temporaere PyInstaller-Artefakte
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
REM + AnwesenheitPDF-Setup-x.y.z.exe (Windows-Installer)
REM
REM Voraussetzungen:
REM pip install -r requirements-dev.txt
REM Inno Setup 6 installiert (https://jrsoftware.org/isinfo.php)
cd /d "%~dp0"
echo.
echo === AnwesenheitPDF Build ===
echo.
echo [1/4] Abhaengigkeiten installieren...
pip install -r requirements.txt --quiet && pip install -r requirements-dev.txt --quiet
if %ERRORLEVEL% neq 0 (
echo [FEHLER] pip install fehlgeschlagen.
pause
exit /b 1
)
echo [2/4] Version aktualisieren...
python update_version.py
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Version-Update fehlgeschlagen.
pause
exit /b 1
)
echo [3/4] Build starten...
echo.
python -m PyInstaller ^
--noconfirm ^
--onedir ^
--windowed ^
--distpath dist ^
--workpath build ^
--specpath build ^
--icon "%~dp0assets\icon.ico" ^
--add-data "%~dp0template;template" ^
--add-data "%~dp0config.json;." ^
--add-data "%~dp0schema.json;." ^
--add-data "%~dp0assets;assets" ^
--name "AnwesenheitPDF" ^
gui.py
if %ERRORLEVEL% neq 0 (
echo.
echo [FEHLER] Build fehlgeschlagen.
pause
exit /b 1
)
if not exist "dist\AnwesenheitPDF\template" mkdir "dist\AnwesenheitPDF\template"
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
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 [OK] Build abgeschlossen.
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
echo Template: dist\AnwesenheitPDF\template\sab.pdf
echo Output: dist\AnwesenheitPDF\output
if not "%ISCC_PATH%"=="" (
echo Installer: dist\AnwesenheitPDF-Setup-%APP_VERSION%.exe
)
echo.
echo ... Programm wird gestartet ...
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
+20 -2
View File
@@ -1,4 +1,5 @@
{
"version": "1.0.17+0d5620a",
"antragsnummer": "100740439",
"ort": "Dresden",
"template_pdf": "template/sab.pdf",
@@ -24,10 +25,27 @@
"Nov": "11",
"Dez": "12"
},
"valid_status_codes": ["K", "KK", "E", "F", "P", "U", "A", "V"],
"valid_status_codes": [
"K",
"KK",
"E",
"F",
"P",
"U",
"A",
"V"
],
"plan_hours_value": "6",
"pdf_field_ort": "unterschrift.ort",
"pdf_field_erstellungsdatum": "unterschrift.datum",
"pdf_pauschale_prefix": "pauschale_5",
"pauschale_present_value": "1"
"pauschale_present_value": "1",
"pdf_use_display_values": true,
"pdf_need_appearances": false,
"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\\"
}
+93 -1
View File
@@ -10,6 +10,7 @@ import sys
from pathlib import Path
import click
from PIL import Image
# Add project root to sys.path so 'src' is importable
sys.path.insert(0, str(Path(__file__).parent))
@@ -17,12 +18,20 @@ sys.path.insert(0, str(Path(__file__).parent))
from src.config_loader import load_config
from src.excel_reader import extract_attendance, extract_persons, extract_year_from_filename
from src.pdf_filler import fill_pdf
from src.pdf_render import render_pdf_pages
from src.validators import show_preview_and_confirm, validate
@click.group()
def cli() -> None:
@click.pass_context
def cli(ctx: click.Context) -> None:
"""Anwesenheit PDF Generator — Excel → PDF"""
try:
config = load_config()
version = config.get("version", "unknown")
click.echo(f"Anwesenheit PDF Generator v{version}")
except Exception as exc:
click.echo(f"Fehler beim Laden der Konfiguration: {exc}", err=True)
@cli.command("validate")
@@ -145,5 +154,88 @@ def generate_cmd(excel_file: str, month: str, output_dir: str | None) -> None:
sys.exit(1)
@cli.command("render-image")
@click.option(
"--pdf",
"pdf_file",
required=True,
type=click.Path(exists=True, file_okay=True, dir_okay=False),
help="Pfad zur PDF-Datei, die als Bild gerendert werden soll.",
)
@click.option(
"--out",
"output_path",
required=False,
default=None,
type=click.Path(file_okay=True, dir_okay=True),
help="Ausgabepfad (Datei für Einzelseite oder Ordner für --all-pages).",
)
@click.option(
"--dpi",
required=False,
default=150,
show_default=True,
type=int,
help="Render-Auflösung in DPI.",
)
@click.option(
"--page",
required=False,
default=1,
show_default=True,
type=int,
help="1-basierter Seitenindex für Einzelseiten-Render.",
)
@click.option(
"--all-pages",
is_flag=True,
default=False,
help="Alle Seiten rendern (ignoriert --page).",
)
def render_image_cmd(
pdf_file: str,
output_path: str | None,
dpi: int,
page: int,
all_pages: bool,
) -> None:
"""Rendert eine PDF direkt als PNG-Bild(er) über die Regression-Renderfunktion."""
pdf_path = Path(pdf_file).resolve()
images = render_pdf_pages(pdf_path, dpi=dpi)
if not images:
click.echo("Keine Seiten in der PDF gefunden.", err=True)
sys.exit(1)
if all_pages:
if output_path:
out_dir = Path(output_path).resolve()
else:
out_dir = pdf_path.parent / f"{pdf_path.stem}_images"
out_dir.mkdir(parents=True, exist_ok=True)
for idx, img in enumerate(images, start=1):
out_file = out_dir / f"{pdf_path.stem}_seite_{idx:02d}.png"
Image.fromarray(img).save(out_file)
click.echo(f"[OK] {out_file}")
return
if page < 1 or page > len(images):
click.echo(
f"Ungültige Seite: {page}. PDF hat {len(images)} Seite(n).",
err=True,
)
sys.exit(1)
if output_path:
out_file = Path(output_path).resolve()
else:
out_file = pdf_path.parent / f"{pdf_path.stem}_seite_{page:02d}.png"
out_file.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(images[page - 1]).save(out_file)
click.echo(f"[OK] {out_file}")
if __name__ == "__main__":
cli()
+1151
View File
File diff suppressed because it is too large. Load diff
+99
View File
@@ -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"
+1
View File
@@ -3,3 +3,4 @@ pytest>=7.0.0
pymupdf>=1.23.0
numpy>=1.26.0
Pillow>=10.0.0
pyinstaller>=6.0.0
+1
View File
@@ -1,3 +1,4 @@
openpyxl>=3.1.2
pypdf>=4.0.0
click>=8.1.0
customtkinter>=5.2.0
+40
View File
@@ -4,6 +4,7 @@
"description": "Konfigurationsschema für config.json",
"type": "object",
"required": [
"version",
"antragsnummer",
"ort",
"template_pdf",
@@ -20,6 +21,11 @@
"pauschale_value"
],
"properties": {
"version": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$",
"description": "Semantic version (z. B. 1.0.0)"
},
"antragsnummer": {
"type": "string",
"description": "SAB-Antragsnummer (statisch, aus config)"
@@ -80,6 +86,40 @@
"pauschale_value": {
"type": "string",
"description": "Wert für das pauschale.N Feld wenn anwesend"
},
"pdf_use_display_values": {
"type": "boolean",
"description": "true = Dropdown-Anzeigewerte ins PDF schreiben (z.B. 'X = anwesend'); false = rohe Export-Werte (z.B. 'X')",
"default": true
},
"pdf_need_appearances": {
"type": "boolean",
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
"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": {
"type": "string",
"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"
}
}
}
+7
View File
@@ -64,6 +64,13 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]:
f"config.json fehlen Pflichtfelder: {', '.join(missing)}"
)
# Optional settings with backward-compatible defaults
data.setdefault("pdf_need_appearances", False)
data.setdefault(
"excel_network_source",
r"J:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste\Anwesenheitsliste 2026.xlsx",
)
_config_cache = data
_config_path_cache = config_path
return data
+26
View File
@@ -139,6 +139,30 @@ def get_sheet_names(file_path: str | Path) -> List[str]:
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(
file_path: str | Path,
sheet_name: str,
@@ -158,6 +182,7 @@ def extract_persons(
wb = _open_workbook(Path(file_path))
try:
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, config["month_mapping"])
data = _sheet_data(wb, sheet_name)
finally:
wb.close()
@@ -217,6 +242,7 @@ def extract_attendance(
wb = _open_workbook(Path(file_path))
try:
sheet_name = resolve_sheet_name(sheet_name, wb.sheetnames, month_mapping)
data = _sheet_data(wb, sheet_name)
finally:
wb.close()
+125 -9
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Any, Dict, Optional
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject, NumberObject
from pypdf.generic import BooleanObject, NameObject, NumberObject, TextStringObject
from .config_loader import load_config
from .exceptions import PdfTemplateError
@@ -59,6 +59,8 @@ def build_field_dict(
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
for idx, entry in enumerate(attendance.entries):
@@ -68,12 +70,20 @@ def build_field_dict(
if entry.is_present:
fields[f"ist.{idx}"] = entry.hours_german()
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
if entry.hours is not None and entry.hours >= plan_threshold:
fields[f"{pauschale_prefix}.{idx}"] = pauschale_present_value
pauschale_5_count += 1
else:
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:
fields[f"ist.{idx}"] = ""
fields[f"plan.{idx}"] = ""
@@ -86,11 +96,56 @@ def build_field_dict(
fields["pauschale_5_sum"] = f"{pauschale_5_euro:.2f}"
fields["pauschale_25_sum"] = "0.00"
fields["pauschale_sum"] = f"{pauschale_5_euro:.2f}"
fields["anwesend_sum"] = str(pauschale_5_count)
fields["anwesend_sum"] = str(qualifying_attendance_count)
return fields
def _load_opt_mappings(reader: PdfReader) -> Dict[str, Dict[str, str]]:
"""Return export→display maps for every choice field in the template.
For a field named ``anwesend.0`` with /Opt ``[["X", "X = anwesend"], ...]``
the result contains ``{"anwesend": {"X": "X = anwesend", ...}}``.
Multiple indexed fields (e.g. ``anwesend.0``…``anwesend.22``) share the
same base name and therefore the same mapping.
"""
opt_maps: Dict[str, Dict[str, str]] = {}
fields = reader.get_fields() or {}
for name, field in fields.items():
if str(field.get("/FT", "")) != "/Ch":
continue
base = name.split(".")[0]
if base in opt_maps:
continue # already recorded from an earlier indexed copy
raw_opts = field.get("/Opt", [])
mapping: Dict[str, str] = {}
for opt in raw_opts:
if isinstance(opt, list) and len(opt) == 2:
mapping[str(opt[0])] = str(opt[1])
else:
s = str(opt)
mapping[s] = s
if mapping:
opt_maps[base] = mapping
return opt_maps
def _apply_opt_mappings(
fields: Dict[str, str], opt_maps: Dict[str, Dict[str, str]]
) -> None:
"""Replace export values with display values in *fields* in-place.
Iterates over every field whose base name exists in *opt_maps* and
substitutes the stored export value for its display counterpart.
Fields whose value is not found in the map are left unchanged.
"""
for field_name, value in list(fields.items()):
base = field_name.split(".")[0]
mapping = opt_maps.get(base)
if mapping and value in mapping:
fields[field_name] = mapping[value]
def _update_widget_visibility(writer: PdfWriter, fields: Dict[str, str]) -> None:
"""Un-hide widget annotations for ist.N and plan.N fields that have a value.
@@ -99,9 +154,23 @@ def _update_widget_visibility(writer: PdfWriter, fields: Dict[str, str]) -> None
hidden bit at runtime when ``anwesend.N`` is selected. Because we write
values directly without executing JavaScript we must clear the hidden bit
ourselves for every row where a value is present.
Also patches the Default Appearance (/DA) of summary fields: the template
uses ``1 g`` (white fill color) because JavaScript normally writes the /AP.
With NeedAppearances=True pymupdf regenerates /AP from /DA — white text on
a white background is invisible. Replace ``1 g`` with ``0 g`` (black) so
the regenerated text is visible.
"""
HIDDEN_FIELD_PREFIXES = ("ist.", "plan.")
HIDDEN_BIT = 2 # PDF annotation flag bit 2 = Hidden
# Summary fields whose /DA may use white text (1 g); patch to black (0 g).
SUM_FIELDS = {
"anwesend_sum",
"anwesend_gessum",
"pauschale_5_sum",
"pauschale_25_sum",
"pauschale_sum",
}
for page in writer.pages:
annots = page.get("/Annots")
@@ -117,9 +186,8 @@ def _update_widget_visibility(writer: PdfWriter, fields: Dict[str, str]) -> None
else:
full_name = t
if not any(full_name.startswith(p) for p in HIDDEN_FIELD_PREFIXES):
continue
# --- Hidden-bit fix for ist.N / plan.N ---
if any(full_name.startswith(p) for p in HIDDEN_FIELD_PREFIXES):
has_value = bool(fields.get(full_name, ""))
current_f = int(annot.get("/F", 4))
# Remove hidden bit when value present, keep/set it when absent
@@ -127,6 +195,12 @@ def _update_widget_visibility(writer: PdfWriter, fields: Dict[str, str]) -> None
if new_f != current_f:
annot[NameObject("/F")] = NumberObject(new_f)
# --- /DA color fix for summary fields ---
if full_name in SUM_FIELDS:
da = str(annot.get("/DA", ""))
if da and "1 g" in da:
annot[NameObject("/DA")] = TextStringObject(da.replace("1 g", "0 g"))
def fill_pdf(
attendance: MonthlyAttendance,
@@ -162,6 +236,35 @@ def fill_pdf(
f"PDF-Template konnte nicht gelesen werden: {exc}"
) from exc
# Optionally replace export values with display values for choice fields.
# Controlled by the config key ``pdf_use_display_values`` (default: True).
# When True, pymupdf/viewers render e.g. "X = anwesend" instead of "X".
if config.get("pdf_use_display_values", True):
opt_maps = _load_opt_mappings(reader)
# Prefill every choice field that our data logic didn't touch so that
# its template-default export value also gets mapped to a display value.
# This covers e.g. pauschale_25.N (never set by us, always "0" in template)
# and anwesend.N / pauschale_5.N for rows beyond the data entries.
for field_name, field in (reader.get_fields() or {}).items():
if field_name in fields:
continue
if str(field.get("/FT", "")) != "/Ch":
continue
export_val = str(field.get("/V", ""))
if export_val:
fields[field_name] = export_val
_apply_opt_mappings(fields, opt_maps)
SUMMARY_FIELDS_FOR_AP = (
"anwesend_sum",
"anwesend_gessum",
"pauschale_5_sum",
"pauschale_25_sum",
"pauschale_sum",
)
writer = PdfWriter()
writer.clone_document_from_reader(reader)
@@ -170,11 +273,24 @@ def fill_pdf(
_update_widget_visibility(writer, fields)
# Tell the PDF viewer to regenerate appearance streams from field values.
# Without this flag, dropdown fields (plan, pauschale) display stale text
# instead of their actual selected option (e.g. "8,60" for pauschale).
# Second pass: regenerate AP only for summary text fields.
# This keeps footer/buttons visible with NeedAppearances=False while still
# rendering summary values in image-based comparisons.
summary_fields = {
name: fields[name]
for name in SUMMARY_FIELDS_FOR_AP
if name in fields
}
if summary_fields:
for page in writer.pages:
writer.update_page_form_field_values(page, summary_fields, auto_regenerate=True)
# Configure viewer-side appearance regeneration via config switch.
# Default is False for stable pymupdf rendering (footer/buttons visible).
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(
bool(config.get("pdf_need_appearances", False))
)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as fh:
+24
View File
@@ -0,0 +1,24 @@
"""Shared PDF rasterization helpers used by tests and CLI."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pymupdf
def render_pdf_pages(pdf_path: Path, dpi: int = 150) -> list[np.ndarray]:
"""Render all pages of *pdf_path* to RGB numpy arrays using pymupdf."""
zoom = dpi / 72.0
mat = pymupdf.Matrix(zoom, zoom)
doc = pymupdf.open(str(pdf_path))
images: list[np.ndarray] = []
for page in doc:
pix = page.get_pixmap(matrix=mat)
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
if pix.n == 4:
img = img[:, :, :3]
images.append(img.copy())
doc.close()
return images
+8 -5
View File
@@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from .config_loader import load_config
from .excel_reader import resolve_sheet_name
from .exceptions import ConfigurationError
@@ -71,18 +72,19 @@ def validate(
)
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"]:
errors.append(
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(
f"Sheet '{sheet_name}' existiert nicht in der Arbeitsmappe. "
f"Verfügbar: {sheet_names}"
)
else:
ws = wb[sheet_name]
ws = wb[resolved]
rows = list(ws.iter_rows(values_only=True))
name_col: int = config["excel_name_column"]
@@ -179,11 +181,12 @@ def _extract_samples(
try:
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()
return result
ws = wb[sheet_name]
ws = wb[resolved]
rows = list(ws.iter_rows(values_only=True))
wb.close()
+23 -3
View File
@@ -128,21 +128,30 @@ class TestAbsentDay:
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
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):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
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):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
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):
fields = build_field_dict(_make_attendance(_absent(4, status)), cfg, date(2026, 2, 1))
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
@@ -177,6 +186,17 @@ class TestSummaryFields:
# Day 1 (6h) and day 2 (7h) qualify, day 3 (5h) does not
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):
"""pauschale_5_sum = qualifying days * 8.60 (period decimal for JS)."""
entries = [_present(1, 6.0), _present(2, 7.0), _present(3, 5.0)]
+79 -37
View File
@@ -15,12 +15,12 @@ A fixture is discovered automatically when:
Rendering
---------
Both reference and generated PDFs are rendered to images using pymupdf
(no browser required). The images are then compared pixel-by-pixel.
Both reference and generated PDFs are rendered to images using pymupdf.
The images are then compared pixel-by-pixel.
Similarity threshold
--------------------
SIMILARITY_THRESHOLD (default 95 %) — fraction of pixels whose R, G, B channels
SIMILARITY_THRESHOLD (100%) — fraction of pixels whose R, G, B channels
each differ by at most PIXEL_TOLERANCE (default 5) between reference and generated PDF.
Diff images
@@ -33,21 +33,25 @@ When a page fails the threshold, three images are saved to
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pymupdf
import pytest
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject
from PIL import Image
from src.pdf_render import render_pdf_pages
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
FIXTURES_DIR = Path(__file__).parent / "fixtures"
SIMILARITY_THRESHOLD = 0.95 # minimum fraction of matching pixels per page
SIMILARITY_THRESHOLD = 1.00 # minimum fraction of matching pixels per page
PIXEL_TOLERANCE = 5 # max per-channel delta still counted as "same"
RENDER_DPI = 150 # rendering resolution for PDF → image
@@ -104,27 +108,6 @@ def _discover_fixture_params() -> list:
return params
# ---------------------------------------------------------------------------
# PDF rendering via pymupdf
# ---------------------------------------------------------------------------
def _render_pdf_pages(pdf_path: Path, dpi: int = RENDER_DPI) -> list[np.ndarray]:
"""Render all pages of a PDF to RGB numpy arrays using pymupdf."""
zoom = dpi / 72.0
mat = pymupdf.Matrix(zoom, zoom)
doc = pymupdf.open(str(pdf_path))
images = []
for page in doc:
pix = page.get_pixmap(matrix=mat)
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
# Convert RGBA → RGB if needed
if pix.n == 4:
img = img[:, :, :3]
images.append(img.copy())
doc.close()
return images
# ---------------------------------------------------------------------------
# Comparison helpers
# ---------------------------------------------------------------------------
@@ -136,26 +119,59 @@ def _resize_to_match(img: np.ndarray, target_shape: tuple) -> np.ndarray:
return np.array(pil)
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float:
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE."""
def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
"""Set /AcroForm /NeedAppearances on *pdf_path* in-place."""
reader = PdfReader(str(pdf_path))
writer = PdfWriter()
writer.clone_document_from_reader(reader)
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(enabled)
with pdf_path.open("wb") as fh:
writer.write(fh)
def _compute_similarity(ref: np.ndarray, gen: np.ndarray, mask_footer: bool = False) -> tuple[float, int, int]:
"""Return (similarity, different_pixels, total_pixels).
If mask_footer=True, ignores the bottom ~20% of each image (footer area).
"""
if ref.shape != gen.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))
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:
"""Save reference, generated, and heatmap diff images for a failing page."""
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.
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:
gen = _resize_to_match(gen, ref.shape)
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(gen).save(str(out_dir / f"{page_label}_generated.png"))
# 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))
max_diff_per_pixel = diff.max(axis=2)
severity = max_diff_per_pixel / 255.0
@@ -167,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[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"))
@@ -186,6 +208,17 @@ def test_pdf_matches_reference(
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
# ------------------------------------------------------------------
@@ -234,9 +267,14 @@ def test_pdf_matches_reference(
failures.append(f" FEHLT: '{ref_pdf.name}' wurde nicht generiert.")
continue
# Regression-specific render behavior: keep NeedAppearances disabled.
# With pymupdf, NeedAppearances=True can hide widget AP content
# (footer and bottom-right buttons) during rasterization.
_set_need_appearances(generated_pdf, enabled=False)
# Render both PDFs to images
ref_images = _render_pdf_pages(ref_pdf)
gen_images = _render_pdf_pages(generated_pdf)
ref_images = render_pdf_pages(ref_pdf, RENDER_DPI)
gen_images = render_pdf_pages(generated_pdf, RENDER_DPI)
page_count = max(len(ref_images), len(gen_images))
for page_idx in range(page_count):
@@ -249,18 +287,22 @@ def test_pdf_matches_reference(
failures.append(f" {page_label}: Seite fehlt in generierter PDF.")
continue
similarity = _compute_similarity(ref_images[page_idx], gen_images[page_idx])
ref_img = ref_images[page_idx]
gen_img = gen_images[page_idx]
similarity, diff_pixels, total_pixels = _compute_similarity(ref_img, gen_img, mask_footer=mask_footer)
if similarity < SIMILARITY_THRESHOLD:
page_tag = f"seite_{page_idx + 1:02d}"
img_dir = diff_dir / clean_stem / page_tag
_save_diff_images(
ref_images[page_idx], gen_images[page_idx], img_dir, page_tag
ref_img, gen_img, img_dir, page_tag, mask_footer=mask_footer
)
rel = img_dir.relative_to(project_root)
failures.append(
f" {page_label}: Ähnlichkeit {similarity:.1%} "
f"(Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
f" {page_label}: Ähnlichkeit {similarity:.2%} "
f"({diff_pixels:,} / {total_pixels:,} Pixel unterschiedlich, "
f"Schwelle {SIMILARITY_THRESHOLD:.0%}) → Bilder: {rel}/"
)
if failures:
+90
View File
@@ -0,0 +1,90 @@
"""Update version in config.json with Git commit hash."""
import json
import re
import subprocess
import sys
from pathlib import Path
def get_git_commit_hash() -> str:
"""Get the short Git commit hash. Return 'unknown' if Git not available."""
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
cwd=Path(__file__).parent,
)
if result.returncode == 0:
return result.stdout.strip()
return "unknown"
except Exception:
return "unknown"
def parse_version(version_str: str) -> tuple[int, int, int]:
"""Parse semantic version and return (major, minor, patch)."""
# Remove any build metadata (e.g., "+abc123")
base_version = version_str.split("+")[0].strip()
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", base_version)
if not match:
raise ValueError(f"Invalid semantic version format: {base_version}")
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def increment_patch_version(version_str: str) -> str:
"""Increment patch version (e.g., 1.0.0 → 1.0.1)."""
major, minor, patch = parse_version(version_str)
return f"{major}.{minor}.{patch + 1}"
def update_version() -> None:
"""Update config.json version with incremented patch and Git hash."""
config_path = Path(__file__).parent / "config.json"
if not config_path.exists():
print(f"[ERROR] config.json not found at {config_path}")
sys.exit(1)
# Load config
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
except Exception as exc:
print(f"[ERROR] Failed to read config.json: {exc}")
sys.exit(1)
# Get current version
current_version = config.get("version", "1.0.0")
print(f"Current version: {current_version}")
# Increment patch version
new_base_version = increment_patch_version(current_version)
print(f"New version (base): {new_base_version}")
# Get Git commit hash
git_hash = get_git_commit_hash()
print(f"Git commit hash: {git_hash}")
# Combine version with Git hash (Semantic Versioning build metadata)
new_version = f"{new_base_version}+{git_hash}"
print(f"Final version: {new_version}")
# Update config
config["version"] = new_version
# Write back to config.json
try:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
print(f"\n✓ Version updated in config.json")
except Exception as exc:
print(f"[ERROR] Failed to write config.json: {exc}")
sys.exit(1)
if __name__ == "__main__":
update_version()