Compare commits

...
5 Commits
11 changed files with 549 additions and 24 deletions

No files matched your search

+35
View File
@@ -61,3 +61,38 @@ 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`
- Optional Desktop-Verknüpfung (Checkbox im Wizard)
+1
View File
@@ -13,3 +13,4 @@ tests/fixtures/**/*.xlsx
tests/fixtures/**/*.xlsm
tests/fixtures/**/reference/*.pdf
tests/fixtures/**/diff/
*.log
+98
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
@@ -198,3 +251,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 |
+74 -7
View File
@@ -1,12 +1,14 @@
@echo off
REM Build-Script fuer AnwesenheitPDF.exe
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"
@@ -14,7 +16,7 @@ echo.
echo === AnwesenheitPDF Build ===
echo.
echo [1/3] Abhaengigkeiten installieren...
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.
@@ -22,7 +24,7 @@ if %ERRORLEVEL% neq 0 (
exit /b 1
)
echo [2/3] Version aktualisieren...
echo [2/4] Version aktualisieren...
python update_version.py
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Version-Update fehlgeschlagen.
@@ -30,7 +32,7 @@ if %ERRORLEVEL% neq 0 (
exit /b 1
)
echo [3/3] Build starten...
echo [3/4] Build starten...
echo.
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 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 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'; 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 %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
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"
+7 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.1+250a473",
"version": "1.0.15+d07b483",
"antragsnummer": "100740439",
"ort": "Dresden",
"template_pdf": "template/sab.pdf",
@@ -42,5 +42,10 @@
"pauschale_present_value": "1",
"pdf_use_display_values": true,
"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\\"
}
+133 -1
View File
@@ -2,13 +2,15 @@
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
# Build-Artefakte: build/ (temporaer, nicht committen)
import json
import os
import re
import shutil
import sys
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
import customtkinter as ctk
import tkinter as tk
@@ -99,6 +101,78 @@ def _count_output_pdfs(config: Dict[str, Any]) -> int:
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)
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 _build_month_keys(config: Dict[str, Any]) -> List[str]:
"""Return deduplicated month keys from config (preserves insertion order)."""
seen: set = set()
@@ -178,6 +252,7 @@ class AnwesenheitApp(ctk.CTk):
self._tooltip_label: Optional[tk.Label] = None
# Build UI sections
self._build_update_banner()
self._build_input_section()
self._build_button_section()
self._build_preview_section()
@@ -185,11 +260,68 @@ class AnwesenheitApp(ctk.CTk):
# Auto-sync after event loop starts
self.after(100, self._start_initial_sync)
# Daily update check
if _should_check_update():
self.after(200, self._start_update_check)
# ------------------------------------------------------------------
# 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:
frame = ctk.CTkFrame(self)
frame.pack(fill="x", padx=16, pady=(16, 0))
+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"
+20
View File
@@ -97,9 +97,29 @@
"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"
}
}
}
+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()
+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()
+48 -9
View File
@@ -33,6 +33,7 @@ When a page fails the threshold, three images are saved to
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
@@ -131,26 +132,46 @@ def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
writer.write(fh)
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 _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
@@ -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[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"))
@@ -181,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
# ------------------------------------------------------------------
@@ -252,18 +290,19 @@ def test_pdf_matches_reference(
ref_img = ref_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:
page_tag = f"seite_{page_idx + 1:02d}"
img_dir = diff_dir / clean_stem / page_tag
_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)
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: