Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102b485794 | ||
|
|
250a473253 | ||
|
|
9d0390f8fd | ||
|
|
6e9d865a9d | ||
|
|
34e9aa49d6 | ||
|
|
34fe877bb2 | ||
|
|
bf89d116f2 | ||
|
|
2433478680 | ||
|
|
c51eddcb73 | ||
|
|
bf671a9acd |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -3,6 +3,11 @@ earlier_versions/
|
||||
output
|
||||
__pycache__
|
||||
|
||||
# PyInstaller build artifacts
|
||||
build/
|
||||
dist/
|
||||
AnwesenheitPDF.spec
|
||||
|
||||
# Fixture-Testdaten (personenbezogene Daten — nicht versioniert)
|
||||
tests/fixtures/**/*.xlsx
|
||||
tests/fixtures/**/*.xlsm
|
||||
|
||||
@@ -86,6 +86,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 +119,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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 153 B |
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
REM Build-Script fuer AnwesenheitPDF.exe
|
||||
REM
|
||||
REM Ergebnis:
|
||||
REM build/ - temporaere PyInstaller-Artefakte
|
||||
REM dist/ - fertiges Programmverzeichnis (AnwesenheitPDF.exe + alle Abhaengigkeiten)
|
||||
REM
|
||||
REM Voraussetzungen:
|
||||
REM pip install -r requirements-dev.txt
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo === AnwesenheitPDF Build ===
|
||||
echo.
|
||||
|
||||
echo [1/3] 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/3] Version aktualisieren...
|
||||
python update_version.py
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Version-Update fehlgeschlagen.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/3] 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 [OK] Build abgeschlossen.
|
||||
echo Programm: dist\AnwesenheitPDF\AnwesenheitPDF.exe
|
||||
echo Template: dist\AnwesenheitPDF\template\sab.pdf
|
||||
echo Output: dist\AnwesenheitPDF\output
|
||||
echo.
|
||||
echo ... Programm wird gestartet ...
|
||||
start "" "dist\AnwesenheitPDF\AnwesenheitPDF.exe"
|
||||
+16
-3
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"version": "1.0.1+250a473",
|
||||
"antragsnummer": "100740439",
|
||||
"ort": "Dresden",
|
||||
"template_pdf": "template/sab.pdf",
|
||||
@@ -24,10 +25,22 @@
|
||||
"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,
|
||||
"excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx"
|
||||
}
|
||||
+93
-1
@@ -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()
|
||||
@@ -0,0 +1,843 @@
|
||||
# Build: build.bat ausfuehren (aus dem Projekt-Root).
|
||||
# Ergebnis: dist/AnwesenheitPDF/AnwesenheitPDF.exe + alle Abhaengigkeiten
|
||||
# Build-Artefakte: build/ (temporaer, nicht committen)
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, filedialog
|
||||
|
||||
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.validators import validate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resource_path(relative: str) -> Path:
|
||||
"""Absolute path to a bundled asset. Handles PyInstaller onefile mode."""
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
return Path(sys._MEIPASS) / relative
|
||||
return Path(__file__).parent / relative
|
||||
|
||||
|
||||
def _runtime_dir() -> Path:
|
||||
"""Root directory for runtime folders (input/, output/, template/)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).parent
|
||||
|
||||
|
||||
def _input_dir() -> Path:
|
||||
"""Return the input directory and ensure it exists."""
|
||||
folder = _runtime_dir() / "input"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def _list_input_files() -> List[Path]:
|
||||
"""Return all Excel files from input/ sorted by name."""
|
||||
return sorted(_input_dir().glob("*.xlsx"), key=lambda path: path.name.lower())
|
||||
|
||||
|
||||
def _copy_excel_to_input(source: Path) -> Path:
|
||||
"""Copy a selected Excel file into input/ and return the local file path."""
|
||||
target = _input_dir() / source.name
|
||||
if source.resolve() != target.resolve():
|
||||
shutil.copy2(source, target)
|
||||
return target
|
||||
|
||||
|
||||
def _sync_network_excel(config: Dict[str, Any]) -> tuple[Optional[Path], str]:
|
||||
"""Copy a newer Excel file from the configured network path into input/."""
|
||||
source_value = str(config.get("excel_network_source", "")).strip()
|
||||
if not source_value:
|
||||
return None, "Kein Netzlaufwerk-Pfad konfiguriert."
|
||||
|
||||
source = Path(source_value)
|
||||
if not source.exists():
|
||||
return None, f"Netzlaufwerk-Datei nicht gefunden: {source.name}"
|
||||
|
||||
target = _input_dir() / source.name
|
||||
if not target.exists() or source.stat().st_mtime > target.stat().st_mtime:
|
||||
shutil.copy2(source, target)
|
||||
return target, f"Excel-Datei aktualisiert: {source.name}"
|
||||
|
||||
return target, f"Lokale Excel-Datei ist aktuell: {source.name}"
|
||||
|
||||
|
||||
def _scan_template_folder() -> Optional[Path]:
|
||||
"""Return the single .pdf in template/, or None if 0 or >1 found."""
|
||||
folder = _runtime_dir() / "template"
|
||||
if not folder.exists():
|
||||
return None
|
||||
files = list(folder.glob("*.pdf"))
|
||||
return files[0] if len(files) == 1 else None
|
||||
|
||||
|
||||
def _count_output_pdfs(config: Dict[str, Any]) -> int:
|
||||
"""Count .pdf files in the configured output directory."""
|
||||
out_dir = _runtime_dir() / config.get("output_dir", "output")
|
||||
if not out_dir.exists():
|
||||
return 0
|
||||
return len(list(out_dir.glob("*.pdf")))
|
||||
|
||||
|
||||
def _build_month_keys(config: Dict[str, Any]) -> List[str]:
|
||||
"""Return deduplicated month keys from config (preserves insertion order)."""
|
||||
seen: set = set()
|
||||
keys: List[str] = []
|
||||
for k, v in config.get("month_mapping", {}).items():
|
||||
if v not in seen:
|
||||
seen.add(v)
|
||||
keys.append(k)
|
||||
return keys
|
||||
|
||||
|
||||
def _truncate_middle(text: str, max_length: int = 48) -> str:
|
||||
"""Shorten long text by replacing the middle with '...' ."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
keep = max_length - 3
|
||||
left = keep // 2
|
||||
right = keep - left
|
||||
return f"{text[:left]}...{text[-right:]}"
|
||||
|
||||
|
||||
def _format_creation_date(value: date) -> str:
|
||||
"""Format a creation date for the GUI and PDF preview."""
|
||||
return value.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def _parse_creation_date(value: str) -> date:
|
||||
"""Parse a German-style date from the GUI entry."""
|
||||
return datetime.strptime(value.strip(), "%d.%m.%Y").date()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup: load config once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ctk.set_appearance_mode("System")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
try:
|
||||
_CONFIG: Dict[str, Any] = load_config()
|
||||
_CONFIG_ERROR: Optional[str] = None
|
||||
except Exception as _exc:
|
||||
_CONFIG = {}
|
||||
_CONFIG_ERROR = str(_exc)
|
||||
|
||||
_MONTH_KEYS: List[str] = _build_month_keys(_CONFIG)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AnwesenheitApp(ctk.CTk):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Load config and version
|
||||
config = load_config()
|
||||
version = config.get("version", "unknown")
|
||||
|
||||
self.title(f"Anwesenheit PDF Generator v{version}")
|
||||
self.geometry("720x680")
|
||||
self.resizable(False, False)
|
||||
|
||||
icon_path = _resource_path("assets/icon.ico")
|
||||
if icon_path.exists():
|
||||
self.iconbitmap(str(icon_path))
|
||||
|
||||
# Internal state
|
||||
self._excel_path: Optional[Path] = None
|
||||
self._template_path: Optional[Path] = None
|
||||
self._out_dir: Optional[Path] = None
|
||||
self._input_files: List[Path] = []
|
||||
self._excel_name_to_path: Dict[str, Path] = {}
|
||||
self._tooltip_window: Optional[ctk.CTkToplevel] = None
|
||||
self._tooltip_label: Optional[tk.Label] = None
|
||||
|
||||
# Build UI sections
|
||||
self._build_input_section()
|
||||
self._build_button_section()
|
||||
self._build_preview_section()
|
||||
self._build_log_section()
|
||||
|
||||
# Auto-sync after event loop starts
|
||||
self.after(100, self._start_initial_sync)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_input_section(self) -> None:
|
||||
frame = ctk.CTkFrame(self)
|
||||
frame.pack(fill="x", padx=16, pady=(16, 0))
|
||||
frame.columnconfigure(2, weight=1) # value column expands
|
||||
|
||||
BTN_W = 32
|
||||
|
||||
# Row 0: Excel file
|
||||
ctk.CTkLabel(frame, text="Excel-Datei:", width=110, anchor="w").grid(
|
||||
row=0, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||
)
|
||||
ctk.CTkButton(
|
||||
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||
command=self._browse_excel,
|
||||
).grid(row=0, column=1, padx=(0, 6), pady=6)
|
||||
self._excel_var = ctk.StringVar(value="")
|
||||
self._excel_menu = ctk.CTkOptionMenu(
|
||||
frame,
|
||||
values=["Keine Excel-Datei gefunden"],
|
||||
variable=self._excel_var,
|
||||
command=lambda _: self._on_excel_changed(),
|
||||
width=320,
|
||||
state="disabled",
|
||||
)
|
||||
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||
|
||||
# Row 1: Template
|
||||
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
||||
row=1, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||
)
|
||||
ctk.CTkButton(
|
||||
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||
command=self._browse_template,
|
||||
).grid(row=1, column=1, padx=(0, 6), pady=6)
|
||||
self._lbl_template = ctk.CTkLabel(frame, text="Suche\u2026", anchor="w")
|
||||
self._lbl_template.grid(row=1, column=2, padx=(0, 12), pady=6, sticky="ew")
|
||||
|
||||
# Row 2: Month dropdown
|
||||
ctk.CTkLabel(frame, text="Monat:", width=110, anchor="w").grid(
|
||||
row=2, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||
)
|
||||
self._month_var = ctk.StringVar(value=_MONTH_KEYS[0] if _MONTH_KEYS else "")
|
||||
self._month_menu = ctk.CTkOptionMenu(
|
||||
frame,
|
||||
values=_MONTH_KEYS or ["\u2013"],
|
||||
variable=self._month_var,
|
||||
command=lambda _: self._on_month_changed(),
|
||||
width=150,
|
||||
)
|
||||
self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||
|
||||
# Row 3: Creation date
|
||||
ctk.CTkLabel(frame, text="Erstellungsdatum:", width=110, anchor="w").grid(
|
||||
row=3, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||
)
|
||||
self._creation_date_var = ctk.StringVar(value=_format_creation_date(date.today()))
|
||||
self._entry_creation_date = ctk.CTkEntry(
|
||||
frame,
|
||||
textvariable=self._creation_date_var,
|
||||
width=150,
|
||||
)
|
||||
self._entry_creation_date.grid(row=3, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||
self._entry_creation_date.bind("<FocusOut>", lambda _: self._on_creation_date_changed())
|
||||
self._entry_creation_date.bind("<Return>", lambda _: self._on_creation_date_changed())
|
||||
|
||||
# Row 4: Output dir
|
||||
out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve())
|
||||
ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid(
|
||||
row=4, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||
)
|
||||
ctk.CTkButton(
|
||||
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||
command=self._open_output_folder,
|
||||
).grid(row=4, column=1, padx=(0, 6), pady=6)
|
||||
self._lbl_output_path = ctk.CTkLabel(
|
||||
frame, text=_truncate_middle(out_label), anchor="w"
|
||||
)
|
||||
self._lbl_output_path.grid(
|
||||
row=4, column=2, padx=(0, 12), pady=6, sticky="ew"
|
||||
)
|
||||
self._lbl_output_path.bind(
|
||||
"<Enter>", lambda event: self._show_tooltip(event, out_label)
|
||||
)
|
||||
self._lbl_output_path.bind("<Leave>", lambda _: self._hide_tooltip())
|
||||
self._lbl_output_path.bind(
|
||||
"<Motion>", lambda event: self._move_tooltip(event, out_label)
|
||||
)
|
||||
|
||||
# Row 5: Output warning + "Ordner leeren" link (hidden when 0)
|
||||
warning_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||||
warning_row.grid(row=5, column=2, padx=(0, 12), pady=(0, 6), sticky="ew")
|
||||
self._lbl_warning = ctk.CTkLabel(
|
||||
warning_row, text="", anchor="w", text_color="orange"
|
||||
)
|
||||
self._lbl_warning.pack(side="left")
|
||||
self._lbl_clear_link = ctk.CTkLabel(
|
||||
warning_row, text="", anchor="w",
|
||||
text_color="#EF4444", cursor="hand2",
|
||||
)
|
||||
self._lbl_clear_link.pack(side="left", padx=(6, 0))
|
||||
self._lbl_clear_link.bind("<Button-1>", lambda _: self._confirm_clear_output())
|
||||
|
||||
self._lbl_download_status = ctk.CTkLabel(
|
||||
frame, text="", anchor="w", text_color="#60A5FA"
|
||||
)
|
||||
self._lbl_download_status.grid(
|
||||
row=6, column=2, padx=(0, 12), pady=(0, 8), sticky="ew"
|
||||
)
|
||||
|
||||
def _build_button_section(self) -> None:
|
||||
frame = ctk.CTkFrame(self)
|
||||
frame.pack(fill="x", padx=16, pady=(8, 0))
|
||||
self._btn_generate = ctk.CTkButton(
|
||||
frame,
|
||||
text="Generieren",
|
||||
state="disabled",
|
||||
command=self._start_generate,
|
||||
width=180,
|
||||
height=40,
|
||||
)
|
||||
self._btn_generate.pack(pady=12)
|
||||
|
||||
def _build_preview_section(self) -> None:
|
||||
frame = ctk.CTkFrame(self)
|
||||
frame.pack(fill="x", padx=16, pady=(8, 0))
|
||||
|
||||
ctk.CTkLabel(frame, text="Vorschau", font=ctk.CTkFont(weight="bold")).grid(
|
||||
row=0, column=0, columnspan=4, padx=12, pady=(8, 2), sticky="w"
|
||||
)
|
||||
|
||||
fields = [
|
||||
("Datei:", "_pv_datei"),
|
||||
("Jahr:", "_pv_jahr"),
|
||||
("Monat:", "_pv_monat"),
|
||||
("Antragsnummer:", "_pv_antrag"),
|
||||
("Ort:", "_pv_ort"),
|
||||
("Teilnehmer:", "_pv_teilnehmer"),
|
||||
("Fehler:", "_pv_fehler"),
|
||||
]
|
||||
|
||||
for i, (text, attr) in enumerate(fields):
|
||||
row = 1 + i // 2
|
||||
col_lbl = (i % 2) * 2
|
||||
col_val = col_lbl + 1
|
||||
ctk.CTkLabel(frame, text=text, width=110, anchor="w").grid(
|
||||
row=row, column=col_lbl, padx=(12, 2), pady=3, sticky="w"
|
||||
)
|
||||
lbl = ctk.CTkLabel(frame, text="–", anchor="w", width=220)
|
||||
lbl.grid(row=row, column=col_val, padx=(0, 8), pady=3, sticky="w")
|
||||
setattr(self, attr, lbl)
|
||||
|
||||
def _build_log_section(self) -> None:
|
||||
frame = ctk.CTkFrame(self)
|
||||
frame.pack(fill="both", expand=True, padx=16, pady=(8, 16))
|
||||
frame.rowconfigure(1, weight=1)
|
||||
frame.columnconfigure(0, weight=1)
|
||||
|
||||
ctk.CTkLabel(frame, text="Log", font=ctk.CTkFont(weight="bold")).grid(
|
||||
row=0, column=0, padx=12, pady=(8, 2), sticky="w"
|
||||
)
|
||||
self._log_box = ctk.CTkTextbox(
|
||||
frame,
|
||||
font=ctk.CTkFont(family="Courier New", size=11),
|
||||
state="disabled",
|
||||
)
|
||||
self._log_box.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="nsew")
|
||||
# Configure clickable-link tag and bind events on underlying tk.Text
|
||||
tb = self._log_box._textbox
|
||||
tb.tag_config("pdf_link", foreground="#4FC3F7", underline=True)
|
||||
tb.bind("<Button-1>", self._on_log_click)
|
||||
tb.bind("<Motion>", self._on_log_motion)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Log helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _log(self, text: str) -> None:
|
||||
self._log_box.configure(state="normal")
|
||||
self._log_box.insert("end", text + "\n")
|
||||
self._log_box.see("end")
|
||||
self._log_box.configure(state="disabled")
|
||||
|
||||
def _log_ok(self, text: str) -> None:
|
||||
"""Insert an [OK] line styled as a clickable PDF link."""
|
||||
tb = self._log_box._textbox
|
||||
tb.configure(state="normal")
|
||||
tb.insert("end", text + "\n")
|
||||
insert_line = int(tb.index("end").split(".")[0]) - 1
|
||||
tb.tag_add("pdf_link", f"{insert_line}.0", f"{insert_line}.end")
|
||||
tb.see("end")
|
||||
tb.configure(state="disabled")
|
||||
|
||||
def _clear_log(self) -> None:
|
||||
self._log_box.configure(state="normal")
|
||||
self._log_box.delete("1.0", "end")
|
||||
self._log_box.configure(state="disabled")
|
||||
|
||||
def _show_tooltip(self, event, text: str) -> None:
|
||||
if self._tooltip_window is None:
|
||||
self._tooltip_window = tk.Toplevel(self)
|
||||
self._tooltip_window.wm_overrideredirect(True)
|
||||
self._tooltip_window.attributes("-topmost", True)
|
||||
self._tooltip_label = tk.Label(
|
||||
self._tooltip_window,
|
||||
background="#F8FAFC",
|
||||
foreground="#111827",
|
||||
relief="solid",
|
||||
borderwidth=1,
|
||||
padx=10,
|
||||
pady=6,
|
||||
font=("Segoe UI", 9),
|
||||
)
|
||||
self._tooltip_label.pack()
|
||||
|
||||
if self._tooltip_label is not None:
|
||||
self._tooltip_label.configure(text=text)
|
||||
|
||||
self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}")
|
||||
|
||||
def _move_tooltip(self, event, text: str) -> None:
|
||||
if self._tooltip_window is None:
|
||||
self._show_tooltip(event, text)
|
||||
return
|
||||
self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}")
|
||||
|
||||
def _hide_tooltip(self) -> None:
|
||||
if self._tooltip_window is not None:
|
||||
self._tooltip_window.destroy()
|
||||
self._tooltip_window = None
|
||||
self._tooltip_label = None
|
||||
|
||||
def _set_excel_loading_state(self, text: str) -> None:
|
||||
self._excel_menu.configure(values=[text], state="disabled")
|
||||
self._excel_var.set(text)
|
||||
|
||||
def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
|
||||
self._lbl_download_status.configure(text=text, text_color=color)
|
||||
|
||||
def _get_creation_date(self) -> date:
|
||||
"""Return the parsed creation date from the GUI entry."""
|
||||
return _parse_creation_date(self._creation_date_var.get())
|
||||
|
||||
def _on_log_click(self, event) -> None:
|
||||
tb = self._log_box._textbox
|
||||
idx = tb.index(f"@{event.x},{event.y}")
|
||||
line_start = tb.index(f"{idx} linestart")
|
||||
line_end = tb.index(f"{idx} lineend")
|
||||
line = tb.get(line_start, line_end).strip()
|
||||
if line.startswith("[OK]") and self._out_dir:
|
||||
# Format: "[OK] filename.pdf (i/total)"
|
||||
rest = line[4:].strip()
|
||||
filename = rest.split(" (")[0].strip()
|
||||
pdf_path = self._out_dir / filename
|
||||
if pdf_path.exists():
|
||||
os.startfile(str(pdf_path))
|
||||
|
||||
def _on_log_motion(self, event) -> None:
|
||||
tb = self._log_box._textbox
|
||||
idx = tb.index(f"@{event.x},{event.y}")
|
||||
line_start = tb.index(f"{idx} linestart")
|
||||
line_end = tb.index(f"{idx} lineend")
|
||||
line = tb.get(line_start, line_end).strip()
|
||||
tb.configure(cursor="hand2" if line.startswith("[OK]") else "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output folder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_output_folder(self) -> None:
|
||||
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.startfile(str(out_dir))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Preview helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_preview(self) -> None:
|
||||
for attr in (
|
||||
"_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag",
|
||||
"_pv_ort", "_pv_teilnehmer", "_pv_fehler",
|
||||
):
|
||||
getattr(self, attr).configure(text="–", text_color=("gray10", "gray90"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output warning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_output_warning(self) -> None:
|
||||
count = _count_output_pdfs(_CONFIG)
|
||||
if count > 0:
|
||||
self._lbl_warning.configure(
|
||||
text=f"⚠ {count} PDF-Datei(en) bereits in output/"
|
||||
)
|
||||
self._lbl_clear_link.configure(text="(Ordner leeren)")
|
||||
else:
|
||||
self._lbl_warning.configure(text="")
|
||||
self._lbl_clear_link.configure(text="")
|
||||
|
||||
def _confirm_clear_output(self) -> None:
|
||||
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||
count = _count_output_pdfs(_CONFIG)
|
||||
if count == 0:
|
||||
return
|
||||
confirmed = messagebox.askyesno(
|
||||
"Ordner leeren",
|
||||
f"{count} PDF-Datei(en) in output/ l\u00f6schen?\n\nDieser Vorgang kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
|
||||
icon="warning",
|
||||
parent=self,
|
||||
)
|
||||
if confirmed:
|
||||
for f in out_dir.glob("*.pdf"):
|
||||
try:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._refresh_output_warning()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File browse helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _browse_excel(self) -> None:
|
||||
path = filedialog.askopenfilename(
|
||||
title="Excel-Datei ausw\u00e4hlen",
|
||||
filetypes=[("Excel-Dateien", "*.xlsx"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self._excel_path = _copy_excel_to_input(Path(path))
|
||||
self._refresh_excel_dropdown(selected=self._excel_path)
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_preview()
|
||||
self._clear_log()
|
||||
self._set_download_status(
|
||||
f"Excel-Datei nach input kopiert: {self._excel_path.name}",
|
||||
"#22C55E",
|
||||
)
|
||||
if self._template_path and self._month_var.get():
|
||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||
|
||||
def _browse_template(self) -> None:
|
||||
path = filedialog.askopenfilename(
|
||||
title="Template-PDF ausw\u00e4hlen",
|
||||
filetypes=[("PDF-Dateien", "*.pdf"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self._template_path = Path(path)
|
||||
self._lbl_template.configure(
|
||||
text=self._template_path.name, text_color=("gray10", "gray90")
|
||||
)
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_preview()
|
||||
self._clear_log()
|
||||
if self._excel_path and self._month_var.get():
|
||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||
|
||||
def _refresh_excel_dropdown(self, selected: Optional[Path] = None) -> None:
|
||||
self._input_files = _list_input_files()
|
||||
self._excel_name_to_path = {path.name: path for path in self._input_files}
|
||||
|
||||
if not self._input_files:
|
||||
self._excel_path = None
|
||||
self._excel_menu.configure(values=["Keine Excel-Datei gefunden"], state="disabled")
|
||||
self._excel_var.set("Keine Excel-Datei gefunden")
|
||||
return
|
||||
|
||||
values = [path.name for path in self._input_files]
|
||||
self._excel_menu.configure(values=values, state="normal")
|
||||
|
||||
if selected and selected.name in self._excel_name_to_path:
|
||||
selected_name = selected.name
|
||||
elif self._excel_var.get() in self._excel_name_to_path:
|
||||
selected_name = self._excel_var.get()
|
||||
else:
|
||||
selected_name = values[0]
|
||||
|
||||
self._excel_var.set(selected_name)
|
||||
self._excel_path = self._excel_name_to_path[selected_name]
|
||||
|
||||
def _start_initial_sync(self) -> None:
|
||||
self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...")
|
||||
self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...")
|
||||
threading.Thread(target=self._run_initial_sync, daemon=True).start()
|
||||
|
||||
def _run_initial_sync(self) -> None:
|
||||
try:
|
||||
synced_path, status = _sync_network_excel(_CONFIG)
|
||||
self.after(0, self._finish_initial_sync, synced_path, status, None)
|
||||
except Exception as exc:
|
||||
self.after(0, self._finish_initial_sync, None, "", str(exc))
|
||||
|
||||
def _finish_initial_sync(
|
||||
self,
|
||||
synced_path: Optional[Path],
|
||||
status: str,
|
||||
error: Optional[str],
|
||||
) -> None:
|
||||
if error:
|
||||
self._set_download_status(
|
||||
f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}",
|
||||
"orange",
|
||||
)
|
||||
else:
|
||||
color = "#22C55E" if synced_path else "orange"
|
||||
self._set_download_status(status, color)
|
||||
self._scan_and_validate(selected_excel=synced_path)
|
||||
|
||||
def _on_excel_changed(self) -> None:
|
||||
selected = self._excel_name_to_path.get(self._excel_var.get())
|
||||
self._excel_path = selected
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_preview()
|
||||
self._clear_log()
|
||||
if self._excel_path and self._template_path and self._month_var.get():
|
||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||
|
||||
def _on_creation_date_changed(self) -> None:
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_preview()
|
||||
self._clear_log()
|
||||
if self._excel_path and self._template_path and self._month_var.get():
|
||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_month_changed(self) -> None:
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_preview()
|
||||
self._clear_log()
|
||||
self._scan_and_validate()
|
||||
|
||||
def _scan_and_validate(self, selected_excel: Optional[Path] = None) -> None:
|
||||
if _CONFIG_ERROR:
|
||||
self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}")
|
||||
return
|
||||
|
||||
self._refresh_excel_dropdown(selected=selected_excel)
|
||||
|
||||
# Scan template/
|
||||
self._template_path = _scan_template_folder()
|
||||
template_folder = _runtime_dir() / "template"
|
||||
if self._template_path:
|
||||
self._lbl_template.configure(
|
||||
text=self._template_path.name, text_color=("gray10", "gray90")
|
||||
)
|
||||
elif not template_folder.exists() or not list(template_folder.glob("*.pdf")):
|
||||
self._lbl_template.configure(
|
||||
text="Keine .pdf in template/ gefunden", text_color="red"
|
||||
)
|
||||
else:
|
||||
n = len(list(template_folder.glob("*.pdf")))
|
||||
self._lbl_template.configure(
|
||||
text=f"Mehrere Dateien gefunden ({n}) — nur eine erlaubt",
|
||||
text_color="red",
|
||||
)
|
||||
|
||||
# Refresh output warning
|
||||
self._refresh_output_warning()
|
||||
|
||||
# Start validation thread if all inputs are ready
|
||||
if self._excel_path and self._template_path and self._month_var.get():
|
||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||
else:
|
||||
self._btn_generate.configure(state="disabled")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation (background thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_validate(self) -> None:
|
||||
excel_file = str(self._excel_path)
|
||||
month = self._month_var.get()
|
||||
result: Dict[str, Any] = {
|
||||
"errors": [],
|
||||
"year": 0,
|
||||
"persons": [],
|
||||
"month": month,
|
||||
"creation_date": None,
|
||||
"exception": None,
|
||||
}
|
||||
|
||||
try:
|
||||
result["errors"] = validate(excel_file, month)
|
||||
except Exception as exc:
|
||||
result["exception"] = str(exc)
|
||||
self.after(0, self._on_validate_done, result)
|
||||
return
|
||||
|
||||
try:
|
||||
result["year"] = extract_year_from_filename(excel_file, _CONFIG)
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"Jahr nicht ermittelbar: {exc}")
|
||||
|
||||
try:
|
||||
result["persons"] = extract_persons(excel_file, month, _CONFIG)
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"Teilnehmer nicht lesbar: {exc}")
|
||||
|
||||
try:
|
||||
result["creation_date"] = self._get_creation_date()
|
||||
except ValueError:
|
||||
result["errors"].append(
|
||||
"Erstellungsdatum ungueltig. Bitte Format DD.MM.YYYY verwenden."
|
||||
)
|
||||
|
||||
self.after(0, self._on_validate_done, result)
|
||||
|
||||
def _on_validate_done(self, result: Dict[str, Any]) -> None:
|
||||
if result.get("exception"):
|
||||
self._log(f"[FEHLER] {result['exception']}")
|
||||
self._btn_generate.configure(state="disabled")
|
||||
return
|
||||
|
||||
errors: List[str] = result["errors"]
|
||||
year: int = result["year"]
|
||||
persons = result["persons"]
|
||||
month: str = result["month"]
|
||||
creation_date_value = result["creation_date"]
|
||||
|
||||
month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??")
|
||||
default_color = ("gray10", "gray90")
|
||||
|
||||
self._pv_datei.configure(
|
||||
text=self._excel_path.name if self._excel_path else "–",
|
||||
text_color=default_color,
|
||||
)
|
||||
self._pv_jahr.configure(
|
||||
text=str(year) if year else "–", text_color=default_color
|
||||
)
|
||||
self._pv_monat.configure(
|
||||
text=f"{month} ({month_num})", text_color=default_color
|
||||
)
|
||||
self._pv_antrag.configure(
|
||||
text=_CONFIG.get("antragsnummer", "–"), text_color=default_color
|
||||
)
|
||||
self._pv_ort.configure(
|
||||
text=_CONFIG.get("ort", "–"), text_color=default_color
|
||||
)
|
||||
self._pv_teilnehmer.configure(
|
||||
text=str(len(persons)), text_color=default_color
|
||||
)
|
||||
|
||||
if errors:
|
||||
self._pv_fehler.configure(text=str(len(errors)), text_color="red")
|
||||
self._clear_log()
|
||||
self._log("--- Validierungsfehler ---")
|
||||
for err in errors:
|
||||
self._log(f" [!] {err}")
|
||||
self._btn_generate.configure(state="disabled")
|
||||
else:
|
||||
self._pv_fehler.configure(text="0", text_color=default_color)
|
||||
self._clear_log()
|
||||
self._btn_generate.configure(state="normal")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_generate(self) -> None:
|
||||
try:
|
||||
self._get_creation_date()
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ung\u00fcltiges Datum",
|
||||
"Bitte ein Erstellungsdatum im Format DD.MM.YYYY eingeben.",
|
||||
parent=self,
|
||||
)
|
||||
self._entry_creation_date.focus_set()
|
||||
return
|
||||
|
||||
count = _count_output_pdfs(_CONFIG)
|
||||
if count > 0:
|
||||
answer = messagebox.askyesnocancel(
|
||||
"Ausgabe-Verzeichnis nicht leer",
|
||||
f"{count} PDF-Datei(en) bereits in output/ vorhanden.\n\n"
|
||||
"Inhalt vor der Generierung löschen?",
|
||||
parent=self,
|
||||
)
|
||||
if answer is None: # Cancel
|
||||
return
|
||||
if answer: # Yes → delete existing PDFs
|
||||
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||
for f in out_dir.glob("*.pdf"):
|
||||
try:
|
||||
f.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self._btn_generate.configure(state="disabled")
|
||||
self._clear_log()
|
||||
threading.Thread(target=self._run_generate, daemon=True).start()
|
||||
|
||||
def _run_generate(self) -> None:
|
||||
excel_file = str(self._excel_path)
|
||||
month = self._month_var.get()
|
||||
success_count = 0
|
||||
total = 0
|
||||
|
||||
try:
|
||||
year = extract_year_from_filename(excel_file, _CONFIG)
|
||||
persons = extract_persons(excel_file, month, _CONFIG)
|
||||
creation_date_value = self._get_creation_date()
|
||||
total = len(persons)
|
||||
|
||||
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._out_dir = out_dir
|
||||
|
||||
for i, person in enumerate(persons, 1):
|
||||
try:
|
||||
attendance = extract_attendance(
|
||||
excel_file, month, person, year, _CONFIG
|
||||
)
|
||||
output_filename = f"{person.filename_safe_name}.pdf"
|
||||
output_path = out_dir / output_filename
|
||||
fill_pdf(
|
||||
attendance,
|
||||
self._template_path,
|
||||
output_path,
|
||||
_CONFIG,
|
||||
creation_date=creation_date_value,
|
||||
)
|
||||
self.after(0, self._log_ok, f"[OK] {output_filename} ({i}/{total})")
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
self.after(0, self._log, f"[FEHLER] {person.name}: {exc}")
|
||||
|
||||
except Exception as exc:
|
||||
self.after(0, self._log, f"[FEHLER] {exc}")
|
||||
self.after(0, self._on_generate_done, 0, 0)
|
||||
return
|
||||
|
||||
self.after(0, self._on_generate_done, success_count, total)
|
||||
|
||||
def _on_generate_done(self, success: int, total: int) -> None:
|
||||
self._log(f"\nFertig: {success}/{total} PDFs erzeugt.")
|
||||
self._btn_generate.configure(state="normal")
|
||||
self._refresh_output_warning()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = AnwesenheitApp()
|
||||
app.mainloop()
|
||||
@@ -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,3 +1,4 @@
|
||||
openpyxl>=3.1.2
|
||||
pypdf>=4.0.0
|
||||
click>=8.1.0
|
||||
customtkinter>=5.2.0
|
||||
+20
@@ -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,20 @@
|
||||
"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
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+119
-13
@@ -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
|
||||
@@ -91,6 +91,51 @@ def build_field_dict(
|
||||
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 +144,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,15 +176,20 @@ 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
|
||||
new_f = (current_f & ~HIDDEN_BIT) if has_value else (current_f | HIDDEN_BIT)
|
||||
if new_f != current_f:
|
||||
annot[NameObject("/F")] = NumberObject(new_f)
|
||||
|
||||
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
|
||||
new_f = (current_f & ~HIDDEN_BIT) if has_value else (current_f | HIDDEN_BIT)
|
||||
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(
|
||||
@@ -162,6 +226,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 +263,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:
|
||||
|
||||
@@ -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
|
||||
+33
-30
@@ -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
|
||||
@@ -39,15 +39,18 @@ 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 +107,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,6 +118,19 @@ def _resize_to_match(img: np.ndarray, target_shape: tuple) -> np.ndarray:
|
||||
return np.array(pil)
|
||||
|
||||
|
||||
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) -> float:
|
||||
"""Return the fraction of pixels where all RGB channels differ by at most PIXEL_TOLERANCE."""
|
||||
if ref.shape != gen.shape:
|
||||
@@ -234,9 +229,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,13 +249,16 @@ 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 = _compute_similarity(ref_img, gen_img)
|
||||
|
||||
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
|
||||
)
|
||||
rel = img_dir.relative_to(project_root)
|
||||
failures.append(
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user