315 lines
12 KiB
Python
315 lines
12 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."""
|
|
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
|
|
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
|
|
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
|
|
|
|
# Felder mit automatischer Schriftgrößen-Anpassung bei langem Text
|
|
AUTOSCALE_FIELDS = {"Qualifikation_Field", "Funktion_Field"}
|
|
AUTOSCALE_MAX_CHARS = 35 # ab dieser Länge wird verkleinert
|
|
AUTOSCALE_MIN_FACTOR = 0.75 # minimale Schriftgröße relativ zum Original
|
|
|
|
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 immer redacten, auch wenn kein Wert zum Einsetzen da ist
|
|
page.add_redact_annot(rect)
|
|
page.apply_redactions()
|
|
|
|
if not formatted:
|
|
continue
|
|
|
|
# Automatische Schriftgrößen-Anpassung für lange Texte
|
|
if m.pdf_field in AUTOSCALE_FIELDS and len(formatted) > AUTOSCALE_MAX_CHARS:
|
|
text_width = fitz.get_text_length(formatted, fontname="helv", fontsize=fontsize)
|
|
available_width = rect.width
|
|
if text_width > available_width:
|
|
factor = available_width / text_width
|
|
factor = max(factor, AUTOSCALE_MIN_FACTOR)
|
|
fontsize = fontsize * factor
|
|
|
|
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
|
|
if m.pdf_field in MULTILINE_FIELDS:
|
|
# Doppelte Leerzeilen entfernen
|
|
clean_text = "\n".join(
|
|
line for line in formatted.split("\n") if line.strip()
|
|
)
|
|
if not clean_text:
|
|
continue
|
|
|
|
# Zeilenweise mit automatischem Umbruch schreiben
|
|
lines = clean_text.split("\n")
|
|
line_spacing = fontsize * 1.2 # Zeilenabstand
|
|
y = rect.y0 + fontsize # Startposition (unterhalb Marker-Oberkante)
|
|
|
|
for line in lines:
|
|
# Wort-Umbruch: Zeile in Stücke passend zur Textbox-Breite aufteilen
|
|
max_width = TEXTBOX_WIDTH_PT
|
|
words = line.split(" ")
|
|
current_line = ""
|
|
for word in words:
|
|
test_line = current_line + (" " if current_line else "") + word
|
|
text_width = fitz.get_text_length(test_line, fontname="helv", fontsize=fontsize)
|
|
if text_width <= max_width or not current_line:
|
|
current_line = test_line
|
|
else:
|
|
page.insert_text(
|
|
(rect.x0, y), current_line,
|
|
fontname="helv", fontsize=fontsize,
|
|
)
|
|
y += line_spacing
|
|
current_line = word
|
|
if current_line:
|
|
page.insert_text(
|
|
(rect.x0, y), current_line,
|
|
fontname="helv", fontsize=fontsize,
|
|
)
|
|
y += line_spacing
|
|
else:
|
|
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
|