"""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