| 1 | """Render an assembled report dict (from reporting.build_report) to a real PDF using reportlab. |
| 2 | Kept separate from reporting.py so the core assembly stays dependency-free and testable.""" |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import io |
| 6 | |
| 7 | |
| 8 | def render_pdf(report: dict) -> bytes: |
| 9 | from reportlab.lib import colors |
| 10 | from reportlab.lib.pagesizes import letter |
| 11 | from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
| 12 | from reportlab.lib.units import inch |
| 13 | from reportlab.platypus import ( |
| 14 | SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, |
| 15 | ) |
| 16 | |
| 17 | buf = io.BytesIO() |
| 18 | doc = SimpleDocTemplate(buf, pagesize=letter, title=report.get("title", "Report"), |
| 19 | topMargin=0.7 * inch, bottomMargin=0.7 * inch) |
| 20 | styles = getSampleStyleSheet() |
| 21 | h1 = styles["Title"] |
| 22 | h2 = ParagraphStyle("H2", parent=styles["Heading2"], spaceBefore=14, textColor=colors.HexColor("#1a3a5a")) |
| 23 | body = styles["BodyText"] |
| 24 | story = [Paragraph(report.get("title", "Investigation Report"), h1), Spacer(1, 10)] |
| 25 | |
| 26 | for key, content in report.get("sections", {}).items(): |
| 27 | story.append(Paragraph(key.replace("_", " ").title(), h2)) |
| 28 | if isinstance(content, str): |
| 29 | story.append(Paragraph(content, body)) |
| 30 | elif isinstance(content, list): |
| 31 | if content and isinstance(content[0], dict): |
| 32 | cols = list(content[0].keys()) |
| 33 | data = [cols] + [[_cell(row.get(c)) for c in cols] for row in content[:60]] |
| 34 | story.append(_table(data, Table, TableStyle, colors)) |
| 35 | else: |
| 36 | for item in content: |
| 37 | story.append(Paragraph(f"• {_cell(item)}", body)) |
| 38 | elif isinstance(content, dict): |
| 39 | data = [["Field", "Value"]] + [[k, _cell(v)] for k, v in content.items()] |
| 40 | story.append(_table(data, Table, TableStyle, colors)) |
| 41 | story.append(Spacer(1, 6)) |
| 42 | |
| 43 | doc.build(story) |
| 44 | return buf.getvalue() |
| 45 | |
| 46 | |
| 47 | def _cell(v) -> str: |
| 48 | import json |
| 49 | if isinstance(v, (dict, list)): |
| 50 | return json.dumps(v, default=str)[:120] |
| 51 | return str(v) if v is not None else "-" |
| 52 | |
| 53 | |
| 54 | def _table(data, Table, TableStyle, colors): |
| 55 | t = Table(data, repeatRows=1, hAlign="LEFT") |
| 56 | t.setStyle(TableStyle([ |
| 57 | ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a3a5a")), |
| 58 | ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), |
| 59 | ("FONTSIZE", (0, 0), (-1, -1), 7), |
| 60 | ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")), |
| 61 | ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f2f6fa")]), |
| 62 | ("VALIGN", (0, 0), (-1, -1), "TOP"), |
| 63 | ])) |
| 64 | return t |