commcoach: remove silent fallbacks in PDF export, fix markdown-to-XML conversion with proper regex
Made-with: Cursor
This commit is contained in:
parent
cdb974d658
commit
1e678a8897
2 changed files with 66 additions and 63 deletions
|
|
@ -937,10 +937,8 @@ async def exportDossier(
|
||||||
|
|
||||||
if format == "pdf":
|
if format == "pdf":
|
||||||
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
|
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
|
||||||
if pdfBytes:
|
return Response(content=pdfBytes, media_type="application/pdf",
|
||||||
return Response(content=pdfBytes, media_type="application/pdf",
|
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
|
||||||
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
|
|
||||||
format = "md"
|
|
||||||
|
|
||||||
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
|
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
|
||||||
return Response(content=md, media_type="text/markdown",
|
return Response(content=md, media_type="text/markdown",
|
||||||
|
|
@ -976,10 +974,8 @@ async def exportSession(
|
||||||
|
|
||||||
if format == "pdf":
|
if format == "pdf":
|
||||||
pdfBytes = await renderSessionPdf(session, messages, tasks, scores)
|
pdfBytes = await renderSessionPdf(session, messages, tasks, scores)
|
||||||
if pdfBytes:
|
return Response(content=pdfBytes, media_type="application/pdf",
|
||||||
return Response(content=pdfBytes, media_type="application/pdf",
|
headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
|
||||||
headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
|
|
||||||
format = "md"
|
|
||||||
|
|
||||||
md = buildSessionMarkdown(session, messages, tasks, scores)
|
md = buildSessionMarkdown(session, messages, tasks, scores)
|
||||||
return Response(content=md, media_type="text/markdown",
|
return Response(content=md, media_type="text/markdown",
|
||||||
|
|
|
||||||
|
|
@ -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]],
|
async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]],
|
||||||
tasks: List[Dict[str, Any]], scores: 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."""
|
"""Render a dossier as PDF directly from markdown content via reportlab."""
|
||||||
md = buildDossierMarkdown(context, sessions, tasks, scores)
|
md = buildDossierMarkdown(context, sessions, tasks, scores)
|
||||||
title = context.get("title", "Coaching Dossier")
|
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]],
|
async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]],
|
||||||
tasks: List[Dict[str, Any]], scores: 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."""
|
"""Render a session as PDF directly from markdown content via reportlab."""
|
||||||
md = buildSessionMarkdown(session, messages, tasks, scores)
|
md = buildSessionMarkdown(session, messages, tasks, scores)
|
||||||
title = f"Coaching Session — {_formatDate(session.get('startedAt'))}"
|
title = f"Coaching Session — {_formatDate(session.get('startedAt'))}"
|
||||||
return _markdownToPdf(md, title)
|
return _markdownToPdf(md, title)
|
||||||
|
|
||||||
|
|
||||||
def _markdownToPdf(markdownText: str, title: str) -> Optional[bytes]:
|
def _markdownToPdf(markdownText: str, title: str) -> bytes:
|
||||||
"""Convert markdown text to a styled PDF using reportlab."""
|
"""Convert markdown text to a styled PDF using reportlab. Raises on failure."""
|
||||||
try:
|
import re as _re
|
||||||
from reportlab.lib.pagesizes import A4
|
import io
|
||||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||||
from reportlab.lib.enums import TA_LEFT
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
from reportlab.lib import colors
|
from reportlab.lib.enums import TA_LEFT
|
||||||
except ImportError:
|
from reportlab.lib import colors
|
||||||
logger.warning("reportlab not available for PDF export")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
buf = io.BytesIO()
|
||||||
import io
|
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()
|
baseStyles = getSampleStyleSheet()
|
||||||
sTitle = ParagraphStyle("DocTitle", parent=baseStyles["Title"], fontSize=18, spaceAfter=20, textColor=colors.HexColor("#1e40af"))
|
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"))
|
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"))
|
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)
|
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)
|
sBullet = ParagraphStyle("DocBullet", parent=sBody, leftIndent=18, bulletIndent=6, spaceBefore=2, spaceAfter=2)
|
||||||
sMeta = ParagraphStyle("DocMeta", parent=sBody, fontSize=8, textColor=colors.grey)
|
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"):
|
for line in markdownText.split("\n"):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
story.append(Spacer(1, 4))
|
story.append(Spacer(1, 4))
|
||||||
elif stripped.startswith("# "):
|
elif stripped.startswith("# "):
|
||||||
continue
|
continue
|
||||||
elif stripped.startswith("### "):
|
elif stripped.startswith("### "):
|
||||||
story.append(Paragraph(stripped[4:], sH3))
|
story.append(Paragraph(_escXml(stripped[4:]), sH3))
|
||||||
elif stripped.startswith("## "):
|
elif stripped.startswith("## "):
|
||||||
story.append(Paragraph(stripped[3:], sH2))
|
story.append(Paragraph(_escXml(stripped[3:]), sH2))
|
||||||
elif stripped.startswith("---"):
|
elif stripped.startswith("---"):
|
||||||
story.append(Spacer(1, 8))
|
story.append(Spacer(1, 8))
|
||||||
elif stripped.startswith("*") and stripped.endswith("*"):
|
elif stripped.startswith("*") and stripped.endswith("*") and not stripped.startswith("**"):
|
||||||
story.append(Paragraph(f"<i>{stripped.strip('*')}</i>", sMeta))
|
story.append(Paragraph(f"<i>{_escXml(stripped.strip('*'))}</i>", sMeta))
|
||||||
elif stripped.startswith("- [x] "):
|
elif stripped.startswith("- [x] "):
|
||||||
story.append(Paragraph(f"\u2713 {stripped[6:]}", sBullet))
|
story.append(Paragraph(f"\u2713 {_mdToXml(stripped[6:])}", sBullet))
|
||||||
elif stripped.startswith("- [ ] "):
|
elif stripped.startswith("- [ ] "):
|
||||||
story.append(Paragraph(f"\u25CB {stripped[6:]}", sBullet))
|
story.append(Paragraph(f"\u25CB {_mdToXml(stripped[6:])}", sBullet))
|
||||||
elif stripped.startswith("- "):
|
elif stripped.startswith("- "):
|
||||||
story.append(Paragraph(f"\u2022 {stripped[2:]}", sBullet))
|
story.append(Paragraph(f"\u2022 {_mdToXml(stripped[2:])}", sBullet))
|
||||||
else:
|
else:
|
||||||
text = stripped.replace("**", "<b>").replace("__", "<b>")
|
story.append(Paragraph(_mdToXml(stripped), sBody))
|
||||||
text = text.replace("*", "<i>").replace("_", "<i>")
|
|
||||||
story.append(Paragraph(text, sBody))
|
|
||||||
|
|
||||||
doc.build(story)
|
doc.build(story)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"PDF generation failed: {e}")
|
|
||||||
return None
|
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'<b>\1</b>', text)
|
||||||
|
text = _re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
||||||
|
text = _re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
||||||
|
text = _re.sub(r'_(.+?)_', r'<i>\1</i>', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue