diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py
index 654d6c03..424b94f3 100644
--- a/modules/features/commcoach/routeFeatureCommcoach.py
+++ b/modules/features/commcoach/routeFeatureCommcoach.py
@@ -937,10 +937,8 @@ async def exportDossier(
if format == "pdf":
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
- if pdfBytes:
- return Response(content=pdfBytes, media_type="application/pdf",
- headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
- format = "md"
+ return Response(content=pdfBytes, media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
return Response(content=md, media_type="text/markdown",
@@ -976,10 +974,8 @@ async def exportSession(
if format == "pdf":
pdfBytes = await renderSessionPdf(session, messages, tasks, scores)
- if pdfBytes:
- return Response(content=pdfBytes, media_type="application/pdf",
- headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
- format = "md"
+ return Response(content=pdfBytes, media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
md = buildSessionMarkdown(session, messages, tasks, scores)
return Response(content=md, media_type="text/markdown",
diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py
index 28a4ffc3..13786d3e 100644
--- a/modules/features/commcoach/serviceCommcoachExport.py
+++ b/modules/features/commcoach/serviceCommcoachExport.py
@@ -143,7 +143,7 @@ def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]]
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]:
+ aiService=None) -> bytes:
"""Render a dossier as PDF directly from markdown content via reportlab."""
md = buildDossierMarkdown(context, sessions, tasks, scores)
title = context.get("title", "Coaching Dossier")
@@ -152,71 +152,78 @@ async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any
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]:
+ aiService=None) -> bytes:
"""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 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("reportlab not available for PDF export")
- return None
+def _markdownToPdf(markdownText: str, title: str) -> bytes:
+ """Convert markdown text to a styled PDF using reportlab. Raises on failure."""
+ import re as _re
+ import io
+ 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
- try:
- import io
- buf = io.BytesIO()
- doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=60, leftMargin=60, topMargin=50, bottomMargin=40)
+ 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)
+ 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)]
+ story = [Paragraph(_escXml(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))
+ 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(_escXml(stripped[4:]), sH3))
+ elif stripped.startswith("## "):
+ story.append(Paragraph(_escXml(stripped[3:]), sH2))
+ elif stripped.startswith("---"):
+ story.append(Spacer(1, 8))
+ elif stripped.startswith("*") and stripped.endswith("*") and not stripped.startswith("**"):
+ story.append(Paragraph(f"{_escXml(stripped.strip('*'))}", sMeta))
+ elif stripped.startswith("- [x] "):
+ story.append(Paragraph(f"\u2713 {_mdToXml(stripped[6:])}", sBullet))
+ elif stripped.startswith("- [ ] "):
+ story.append(Paragraph(f"\u25CB {_mdToXml(stripped[6:])}", sBullet))
+ elif stripped.startswith("- "):
+ story.append(Paragraph(f"\u2022 {_mdToXml(stripped[2:])}", sBullet))
+ else:
+ story.append(Paragraph(_mdToXml(stripped), sBody))
- doc.build(story)
- buf.seek(0)
- return buf.getvalue()
- except Exception as e:
- logger.warning(f"PDF generation failed: {e}")
- return None
+ doc.build(story)
+ buf.seek(0)
+ return buf.getvalue()
+
+
+def _escXml(text: str) -> str:
+ """Escape text for reportlab XML paragraphs."""
+ return text.replace("&", "&").replace("<", "<").replace(">", ">")
+
+
+def _mdToXml(text: str) -> str:
+ """Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest."""
+ import re as _re
+ text = text.replace("&", "&").replace("<", "<").replace(">", ">")
+ text = _re.sub(r'\*\*(.+?)\*\*', r'\1', text)
+ text = _re.sub(r'__(.+?)__', r'\1', text)
+ text = _re.sub(r'\*(.+?)\*', r'\1', text)
+ text = _re.sub(r'_(.+?)_', r'\1', text)
+ return text