From cdb974d65863a7f933de7bfc3de73e097778f90a Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Sat, 7 Mar 2026 02:29:30 +0100
Subject: [PATCH] commcoach: rewrite PDF export - direct markdown-to-pdf via
reportlab, remove broken RendererPdf dependency
Made-with: Cursor
---
.../commcoach/serviceCommcoachExport.py | 178 +++++++-----------
1 file changed, 65 insertions(+), 113 deletions(-)
diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py
index 1db6df4e..28a4ffc3 100644
--- a/modules/features/commcoach/serviceCommcoachExport.py
+++ b/modules/features/commcoach/serviceCommcoachExport.py
@@ -140,133 +140,85 @@ def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]]
return "\n".join(lines)
-class _DebugUtils:
- """Minimal stub for services.utils required by RendererPdf."""
- @staticmethod
- def debugLogToFile(message: str, context: str = "DEBUG"):
- logger.debug(f"[{context}] {message}")
-
-
-class _ServicesStub:
- """Minimal services stub providing utils for RendererPdf."""
- def __init__(self):
- self.utils = _DebugUtils()
-
async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]],
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
aiService=None) -> Optional[bytes]:
- """Render a dossier as PDF using the existing RendererPdf."""
- try:
- from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf
- extractedContent = _buildPdfContent(context, sessions, tasks, scores, isDossier=True)
- renderer = RendererPdf(services=_ServicesStub())
- docs = await renderer.render(extractedContent=extractedContent, title=context.get("title", "Dossier"), aiService=aiService)
- if docs and len(docs) > 0:
- return docs[0].documentData
- except ImportError:
- logger.warning("RendererPdf not available, falling back to markdown-based PDF")
- except Exception as e:
- logger.warning(f"PDF rendering failed: {e}")
- return None
+ """Render a dossier as PDF directly from markdown content via reportlab."""
+ md = buildDossierMarkdown(context, sessions, tasks, scores)
+ title = context.get("title", "Coaching Dossier")
+ return _markdownToPdf(md, title)
async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]],
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
aiService=None) -> Optional[bytes]:
- """Render a session as PDF."""
+ """Render a session as PDF directly from markdown content via reportlab."""
+ md = buildSessionMarkdown(session, messages, tasks, scores)
+ title = f"Coaching Session — {_formatDate(session.get('startedAt'))}"
+ return _markdownToPdf(md, title)
+
+
+def _markdownToPdf(markdownText: str, title: str) -> Optional[bytes]:
+ """Convert markdown text to a styled PDF using reportlab."""
try:
- from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf
- title = f"Session {_formatDate(session.get('startedAt'))}"
- extractedContent = _buildPdfContent({"title": title}, [session], tasks, scores, isDossier=False, messages=messages)
- renderer = RendererPdf(services=_ServicesStub())
- docs = await renderer.render(extractedContent=extractedContent, title=title, aiService=aiService)
- if docs and len(docs) > 0:
- return docs[0].documentData
+ from reportlab.lib.pagesizes import A4
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.enums import TA_LEFT
+ from reportlab.lib import colors
except ImportError:
- logger.warning("RendererPdf not available")
+ logger.warning("reportlab not available for PDF export")
+ return None
+
+ try:
+ import io
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=60, leftMargin=60, topMargin=50, bottomMargin=40)
+
+ baseStyles = getSampleStyleSheet()
+ sTitle = ParagraphStyle("DocTitle", parent=baseStyles["Title"], fontSize=18, spaceAfter=20, textColor=colors.HexColor("#1e40af"))
+ sH2 = ParagraphStyle("DocH2", parent=baseStyles["Heading2"], fontSize=13, spaceBefore=16, spaceAfter=6, textColor=colors.HexColor("#1f2937"))
+ sH3 = ParagraphStyle("DocH3", parent=baseStyles["Heading3"], fontSize=11, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#374151"))
+ sBody = ParagraphStyle("DocBody", parent=baseStyles["Normal"], fontSize=10, leading=14, spaceAfter=4, alignment=TA_LEFT)
+ sBullet = ParagraphStyle("DocBullet", parent=sBody, leftIndent=18, bulletIndent=6, spaceBefore=2, spaceAfter=2)
+ sMeta = ParagraphStyle("DocMeta", parent=sBody, fontSize=8, textColor=colors.grey)
+
+ story = [Paragraph(title, sTitle), Spacer(1, 10)]
+
+ for line in markdownText.split("\n"):
+ stripped = line.strip()
+ if not stripped:
+ story.append(Spacer(1, 4))
+ elif stripped.startswith("# "):
+ continue
+ elif stripped.startswith("### "):
+ story.append(Paragraph(stripped[4:], sH3))
+ elif stripped.startswith("## "):
+ story.append(Paragraph(stripped[3:], sH2))
+ elif stripped.startswith("---"):
+ story.append(Spacer(1, 8))
+ elif stripped.startswith("*") and stripped.endswith("*"):
+ story.append(Paragraph(f"{stripped.strip('*')}", sMeta))
+ elif stripped.startswith("- [x] "):
+ story.append(Paragraph(f"\u2713 {stripped[6:]}", sBullet))
+ elif stripped.startswith("- [ ] "):
+ story.append(Paragraph(f"\u25CB {stripped[6:]}", sBullet))
+ elif stripped.startswith("- "):
+ story.append(Paragraph(f"\u2022 {stripped[2:]}", sBullet))
+ else:
+ text = stripped.replace("**", "").replace("__", "")
+ text = text.replace("*", "").replace("_", "")
+ story.append(Paragraph(text, sBody))
+
+ doc.build(story)
+ buf.seek(0)
+ return buf.getvalue()
except Exception as e:
- logger.warning(f"Session PDF rendering failed: {e}")
- return None
+ logger.warning(f"PDF generation failed: {e}")
+ return None
-def _buildPdfContent(context, sessions, tasks, scores, isDossier=True, messages=None) -> Dict[str, Any]:
- """Convert dossier/session data into the extractedContent format expected by RendererPdf."""
- title = context.get("title", "Export")
- sections = []
-
- sections.append({
- "id": "header",
- "content_type": "heading",
- "elements": [{"text": title, "level": 1}],
- })
-
- if isDossier and context.get("description"):
- sections.append({
- "id": "desc",
- "content_type": "paragraph",
- "elements": [{"text": context.get("description")}],
- })
-
- completedSessions = [s for s in sessions if s.get("status") == "completed"] if isDossier else sessions
- if completedSessions:
- sessionRows = []
- for s in completedSessions:
- sessionRows.append({
- "cells": [
- _formatDate(s.get("startedAt") or s.get("createdAt")),
- str(s.get("competenceScore") or "--"),
- s.get("summary", "")[:200] if s.get("summary") else "",
- ]
- })
- sections.append({
- "id": "sessions",
- "content_type": "heading",
- "elements": [{"text": "Sessions", "level": 2}],
- })
- sections.append({
- "id": "sessions_table",
- "content_type": "table",
- "elements": [{
- "headers": ["Datum", "Score", "Zusammenfassung"],
- "rows": sessionRows,
- }],
- })
-
- if messages:
- chatElements = []
- for msg in messages:
- role = "Du" if msg.get("role") == "user" else "Coach"
- chatElements.append({"text": f"{role}: {msg.get('content', '')}"})
- sections.append({
- "id": "chat",
- "content_type": "heading",
- "elements": [{"text": "Gesprächsverlauf", "level": 2}],
- })
- sections.append({
- "id": "chat_content",
- "content_type": "paragraph",
- "elements": chatElements,
- })
-
- if tasks:
- taskItems = [{"text": f"{'[x]' if t.get('status') == 'done' else '[ ]'} {t.get('title')}"} for t in tasks]
- sections.append({
- "id": "tasks",
- "content_type": "heading",
- "elements": [{"text": "Aufgaben", "level": 2}],
- })
- sections.append({
- "id": "task_list",
- "content_type": "bullet_list",
- "elements": taskItems,
- })
-
- return {
- "metadata": {"title": title},
- "documents": [{"id": "main", "title": title, "sections": sections}],
- }
-
def _formatDate(isoStr: Optional[str]) -> str:
if not isoStr: