Files
sufw-taetigkeitsnachweise/src/writer.py
T

258 lines
9.3 KiB
Python

"""PDF-basierte Activity-Report-Generierung.
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
"""
import calendar
from pathlib import Path
import fitz # PyMuPDF
from .models import EmployeeRecord, OutputMapping
# ─────────────────────────────────────────────
# Namens-Helfer
# ─────────────────────────────────────────────
def _split_name_for_filename(name: str) -> tuple[str, str]:
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
cleaned = str(name or "").strip()
if "," in cleaned:
last_name, first_name = [p.strip() for p in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _format_value(value, fmt: str | None) -> str:
"""Formatiert einen Wert gemäß Format-String."""
if value is None:
return ""
if fmt is None:
return str(value)
if "%" in fmt:
try:
return fmt % float(value)
except (ValueError, TypeError):
return str(value)
return str(value)
def _format_month_for_template(monat_str: str) -> str:
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
monat_str = monat_str.strip()
parts = monat_str.split(".")
if len(parts) == 2:
month = parts[0]
year = parts[1]
if len(year) == 2:
year = "20" + year
return f"{month}.{year}"
return monat_str
# ─────────────────────────────────────────────
# Daten-Extraktion aus EmployeeRecord
# ─────────────────────────────────────────────
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
"""Liefert den formatierten Wert für ein Feld im PDF."""
if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
return record.stellenanteil
elif source_field == "Name":
return employee_name
elif source_field == "Antragsnummer":
return record.antragsnummer
elif source_field == "Qualifikation":
return record.qualifikation
elif source_field == "Funktion":
return record.funktion
elif source_field == "Tätigkeit":
return record.taetigkeit
elif source_field in ("Ort", "Ort 2"):
return "Dresden"
elif source_field == "Datum":
parts = record.monat.split(".")
if len(parts) == 2:
try:
month = int(parts[0])
year = int(parts[1])
last_day = calendar.monthrange(year, month)[1]
return f"{last_day:02d}.{month:02d}.{year}"
except (ValueError, IndexError):
return ""
return ""
return ""
# ─────────────────────────────────────────────
# PDF-Template Pre-Scan: _Field-Positionen auf ALLEN Seiten ermitteln
# ─────────────────────────────────────────────
def _scan_template_fields(
template_path: Path,
mapping: list[OutputMapping],
) -> dict[str, dict]:
"""
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
Rückgabe: dict[pdf_field] → {source_field, page_num, rect, fontsize, fontname, ...}
Felder mit pdf_field=None werden übersprungen.
"""
doc = fitz.open(template_path)
# Sammle alle pdf_field → source_field Zuordnungen
pdf_to_source: dict[str, str] = {}
for m in mapping:
if m.pdf_field is not None:
pdf_to_source[m.pdf_field] = m.source_field
field_info: dict[str, dict] = {}
# 1. Per search_for die Rechtecke aller PDF-Feld-Marker finden (alle Seiten)
for pdf_field, source_field in pdf_to_source.items():
found_any = False
for page_num in range(doc.page_count):
page = doc[page_num]
found = page.search_for(pdf_field)
if found:
field_info[pdf_field] = {
"source_field": source_field,
"page_num": page_num,
"rect": found[0],
"fontsize": 11, # Fallback
"fontname": "helv",
}
found_any = True
break # erstes Vorkommen (seitenübergreifend)
if not found_any:
field_info[pdf_field] = None # nicht gefunden
# 2. Schriftgrößen aus den Text-Details extrahieren (alle Seiten)
for page_num in range(doc.page_count):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if "lines" not in block:
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if text.endswith("_Field") and text in field_info:
info = field_info[text]
if info is not None:
info["fontsize"] = span["size"]
info["fontname"] = span.get("font", "helv")
info["rect"] = fitz.Rect(span["bbox"])
doc.close()
return field_info
# ─────────────────────────────────────────────
# Template-Seiten mit Daten befüllen
# ─────────────────────────────────────────────
def _fill_pdf_pages(
pages: list[fitz.Page], # eine Page pro Template-Seite
record: EmployeeRecord,
employee_name: str,
mapping: list[OutputMapping],
field_info: dict[str, dict],
) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
for m in mapping:
if m.pdf_field is None:
continue
info = field_info.get(m.pdf_field)
if info is None:
continue # Feld nicht im Template gefunden
page_num = info["page_num"]
if page_num >= len(pages):
continue
page = pages[page_num]
rect = info["rect"]
fontsize = info.get("fontsize", 11)
value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format)
# Platzhalter redacten
page.add_redact_annot(rect)
page.apply_redactions()
# Neuen Text einfügen
if formatted:
page.insert_text(
rect.bl,
formatted,
fontname="helv",
fontsize=fontsize,
)
# ─────────────────────────────────────────────
# Hauptfunktion: Reports generieren
# ─────────────────────────────────────────────
def generate_activity_reports(
employee_records: dict[str, list[EmployeeRecord]],
template_path: Path,
mapping: list[OutputMapping],
output_dir: Path,
filename_pattern: str,
) -> list[Path]:
"""
Erzeugt für jeden Mitarbeiter ein PDF.
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
"""
output_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[Path] = []
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
field_info = _scan_template_fields(template_path, mapping)
for employee_name, records in employee_records.items():
if not records:
continue
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_").replace("/", "_")
safe_last = last_name.replace(" ", "_").replace("/", "_")
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
output_path = output_dir / filename
sorted_records = sorted(records, key=lambda r: r.month_index)
# Ergebnis-PDF erstellen
result_doc = fitz.open()
for record in sorted_records:
# Template frisch öffnen für jeden Monat
temp_doc = fitz.open(template_path)
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
_fill_pdf_pages(pages, record, employee_name, mapping, field_info)
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
result_doc.insert_pdf(temp_doc)
temp_doc.close()
result_doc.save(output_path)
result_doc.close()
generated_files.append(output_path)
return generated_files