""" PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads. """ from __future__ import annotations import io import logging from typing import Any from xml.sax.saxutils import escape from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import mm from reportlab.platypus import Image as RLImage from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer from db import get_cursor, get_db from report_chart_fetch import fetch_chart_payload from report_chart_plotting import chart_payload_to_png from report_profile_schema import ( AiInsightBlock, ChartBlock, ReportProfilePayload, SectionBlock, VizBundleBlock, ) from report_viz_bundle_pdf import append_viz_bundle_to_story logger = logging.getLogger(__name__) _CONTENT_TRUNCATE = 12000 def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]: """Returns (heading, body_text).""" if not insight_id: return ( "KI-Auswertung", "(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse " "verknüpfen.)", ) try: with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s", (insight_id, profile_id), ) row = cur.fetchone() if not row: return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.") scope = row.get("scope") or "Analyse" content = row.get("content") or "" if len(content) > _CONTENT_TRUNCATE: content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]" created = row.get("created") sub = f"{scope}" + (f" · {created}" if created else "") return (sub, content) except Exception as e: logger.warning("report pdf insight load failed: %s", e) return ("KI-Auswertung", "Fehler beim Laden des Eintrags.") def build_structured_report_pdf( *, profile_id: str, profile_name: str, payload: ReportProfilePayload, ) -> bytes: """Vollständiges PDF als Bytes (A4).""" buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, leftMargin=14 * mm, rightMargin=14 * mm, topMargin=16 * mm, bottomMargin=16 * mm, ) styles = getSampleStyleSheet() story: list[Any] = [] title = (payload.document_title or "").strip() or f"{profile_name} – Bericht" story.append(Paragraph(escape(title), styles["Title"])) story.append(Spacer(1, 6 * mm)) for block in payload.blocks: if isinstance(block, SectionBlock): story.append(Spacer(1, 4 * mm)) story.append(Paragraph(escape(block.title), styles["Heading2"])) story.append(Spacer(1, 2 * mm)) elif isinstance(block, VizBundleBlock): append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config) elif isinstance(block, ChartBlock): try: chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days) except Exception as e: logger.warning("chart fetch %s: %s", block.chart_id, e) story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"])) continue meta = chart.get("metadata") or {} if meta.get("confidence") == "insufficient": msg = meta.get("message") or "Nicht genug Daten" story.append(Paragraph(f"{block.chart_id}: {msg}", styles["Normal"])) story.append(Spacer(1, 3 * mm)) continue try: png = chart_payload_to_png(chart) img_buf = io.BytesIO(png) iw = 170 * mm ih = 85 * mm story.append(RLImage(img_buf, width=iw, height=ih)) except Exception as e: logger.warning("chart render %s: %s", block.chart_id, e) story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"])) story.append(Spacer(1, 4 * mm)) elif isinstance(block, AiInsightBlock): heading, body = _insight_text(profile_id, block.insight_id) if block.title.strip(): story.append(Paragraph(escape(block.title), styles["Heading3"])) else: story.append(Paragraph(escape(heading), styles["Heading3"])) for para in body.split("\n\n"): p = (para or "").strip() if p: story.append(Paragraph(escape(p), styles["BodyText"])) story.append(Spacer(1, 4 * mm)) doc.build(story) return buf.getvalue()