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